From db3fe9f26d53e9824f59af83eaa5305cdcc9e247 Mon Sep 17 00:00:00 2001 From: Asad Ali <14asadali@gmail.com> Date: Tue, 20 Oct 2026 10:52:13 +0500 Subject: [PATCH 01/29] feat(sdk): add the pristine store behind custom code Snapshots of an SDK directory as commits under hidden refs of the repository enclosing it: the pristine chain, one commit per generation, and the previous state before a run writes. A generation record in .apimatic/generation.json names the snapshot the directory was written from. Nothing calls it yet. Co-Authored-By: Claude Fable 5.1 --- package.json | 1 + pnpm-lock.yaml | 183 ++++++++++++++ src/infrastructure/pristine-store-service.ts | 223 +++++++++++++++++ src/types/sdk/commit-id.ts | 25 ++ src/types/sdk/generation-record.ts | 133 +++++++++++ src/types/sdk/sdk-repository.ts | 43 ++++ src/types/sdk/snapshot-files.ts | 2 + src/types/sdk/snapshot-ref.ts | 43 ++++ .../pristine-store-service.test.ts | 225 ++++++++++++++++++ test/types/sdk/generation-record.test.ts | 74 ++++++ test/types/sdk/snapshot-ref.test.ts | 21 ++ 11 files changed, 973 insertions(+) create mode 100644 src/infrastructure/pristine-store-service.ts create mode 100644 src/types/sdk/commit-id.ts create mode 100644 src/types/sdk/generation-record.ts create mode 100644 src/types/sdk/sdk-repository.ts create mode 100644 src/types/sdk/snapshot-files.ts create mode 100644 src/types/sdk/snapshot-ref.ts create mode 100644 test/infrastructure/pristine-store-service.test.ts create mode 100644 test/types/sdk/generation-record.test.ts create mode 100644 test/types/sdk/snapshot-ref.test.ts diff --git a/package.json b/package.json index 28f84f20c..5d8351f20 100644 --- a/package.json +++ b/package.json @@ -83,6 +83,7 @@ "fumadocs-ui": "16.15.17", "get-port": "^7.1.0", "is-in-ci": "^2.0.0", + "isomorphic-git": "^1.44.2", "lucide-react": "1.47.0", "mustache": "^4.2.0", "neverthrow": "^8.2.0", diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 32c1a4f43..197a04733 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -109,6 +109,9 @@ importers: is-in-ci: specifier: ^2.0.0 version: 2.0.0 + isomorphic-git: + specifier: ^1.44.2 + version: 1.44.2 lucide-react: specifier: 1.47.0 version: 1.47.0(react@19.2.8) @@ -2855,62 +2858,73 @@ packages: resolution: {integrity: sha512-Rvr0/EkPfPgc0C1wx8dz4bv6o4I4yeYI06tcHUL0f4ZdwSyIreABoLHNW51Ap0VNA7aDRVQ4izRNQh/LuykofA==} cpu: [arm64] os: [android] + deprecated: yuku-analyzer runs on yuku-core since 0.14 '@yuku-analyzer/binding-darwin-arm64@0.9.5': resolution: {integrity: sha512-sh9DaXzkVmedM9aWvjCAAluRtvz/trk5VSosF7dPhqDf3IhrJW2DOh9iuA+YQzWSktP/ILsKfKili2UZ/ZjRzw==} cpu: [arm64] os: [darwin] + deprecated: yuku-analyzer runs on yuku-core since 0.14 '@yuku-analyzer/binding-darwin-x64@0.9.5': resolution: {integrity: sha512-qPKyWlGK92vjwwFdAKwGkfqF3rye4UR8X2WPvzhOSt/plATd2pPJFyMBhF1aclRMBNbk20YnctWYRYLS8FBgAg==} cpu: [x64] os: [darwin] + deprecated: yuku-analyzer runs on yuku-core since 0.14 '@yuku-analyzer/binding-freebsd-x64@0.9.5': resolution: {integrity: sha512-LeSbxDQ7k0WFyddM3BiFW3i55eSkWwi4hgG8PmqNBGL9dZ4sysYOwcy3We8vyxc/fyhuhc0CYxa6p6v14+UBow==} cpu: [x64] os: [freebsd] + deprecated: yuku-analyzer runs on yuku-core since 0.14 '@yuku-analyzer/binding-linux-arm-gnu@0.9.5': resolution: {integrity: sha512-GVtm5UmUhKMnicVz/Q4QgRr7OFyvxDrr7j9u0Mrgnisj/GmABhcFhzqVwW+rbS+EFnUHY7yIamwY2Fc3CoCTCw==} cpu: [arm] os: [linux] libc: [glibc] + deprecated: yuku-analyzer runs on yuku-core since 0.14 '@yuku-analyzer/binding-linux-arm-musl@0.9.5': resolution: {integrity: sha512-c/9lCWXRwp0eaAvbTZslrplIjaLhiFCdc5bF/hCV13HrYYyICSwEcCgx3/OEEpwfHpdQsqejhsntUKc/0Ylbfg==} cpu: [arm] os: [linux] libc: [musl] + deprecated: yuku-analyzer runs on yuku-core since 0.14 '@yuku-analyzer/binding-linux-arm64-gnu@0.9.5': resolution: {integrity: sha512-BwZYDPfnuP9gh8KphdjClzN3XRV0smbc2nNGkxvSOEYB/uRW0e+S2Jh82QKMSCK5TptQfovmFWe55Z5lToLAfA==} cpu: [arm64] os: [linux] libc: [glibc] + deprecated: yuku-analyzer runs on yuku-core since 0.14 '@yuku-analyzer/binding-linux-arm64-musl@0.9.5': resolution: {integrity: sha512-xglOsssSkPQGwendJdWud5maVWoM6h5SwlouStfg0eUACjxV8uLvnub2NT+euQJQMVzH3aRc5RuPPqzvMFeDTA==} cpu: [arm64] os: [linux] libc: [musl] + deprecated: yuku-analyzer runs on yuku-core since 0.14 '@yuku-analyzer/binding-linux-x64-gnu@0.9.5': resolution: {integrity: sha512-X7J/LWSnNsyx1E4hv6CFofXQft4GMePfc4pDn00B4lG6BO3HNR/zGlYZ5ayQsxhRNLxtAlvSFSaguaSuyz6p6g==} cpu: [x64] os: [linux] libc: [glibc] + deprecated: yuku-analyzer runs on yuku-core since 0.14 '@yuku-analyzer/binding-linux-x64-musl@0.9.5': resolution: {integrity: sha512-gmi9vazzSs4PLpHjgkFLPGCDMzG7ths3mcip0qZEHhLwLJthH5PTA+LYKi+7FRKHNQT4A50RTVox5CjdUC+prw==} cpu: [x64] os: [linux] libc: [musl] + deprecated: yuku-analyzer runs on yuku-core since 0.14 '@yuku-analyzer/binding-win32-arm64@0.9.5': resolution: {integrity: sha512-0iKC5sGOCrFqumzSHLv2qCQa0OTytK605dSJrdHah5CoLT13oStEfk3jwNltYB77J3BiSagg896d5YmTY8HQPA==} cpu: [arm64] os: [win32] + deprecated: yuku-analyzer runs on yuku-core since 0.14 '@yuku-analyzer/binding-win32-x64@0.9.5': resolution: {integrity: sha512-dSb5o6D9JugPmKRsjVLDLpgHRdSd1oCvNhY6AP6+OCzMwn0kbiNxhf2DwXYtt4xIBjliYQnw+ZsvIy1d0lHtSg==} @@ -2920,6 +2934,10 @@ packages: '@yuku-toolchain/types@0.9.5': resolution: {integrity: sha512-KiuLNNgX9uNealaWAR+G3/cMXnRk9x4TY2EkYe/KIag+UPdwiA0RRf1hr1WAxzTP8KGzCTkqUdLPvqh32sEO3w==} + abort-controller@3.0.0: + resolution: {integrity: sha512-h8lQ8tacZYnR3vNQTgibj+tODHI5/+l06Au2Pcriv/Gmet0eaj4TwWH41sO9wnHDiQsEj19q0drzdWdeAHtweg==} + engines: {node: '>=6.5'} + acorn-jsx@5.3.2: resolution: {integrity: sha512-rq9s+JNhf0IChjtDXxllJ7g41oZk5SlXtp0LHwyA5cejwn7vKmKp4pPri6YEePv2PU65sAsegbXtIinmDFDXgQ==} peerDependencies: @@ -3061,6 +3079,9 @@ packages: resolution: {integrity: sha512-hsU18Ae8CDTR6Kgu9DYf0EbCr/a5iGL0rytQDobUcdpYOKokk8LEjVphnXkDkgpi0wYVsqrXuP0bZxJaTqdgoA==} engines: {node: '>= 0.4'} + async-lock@1.4.1: + resolution: {integrity: sha512-Az2ZTpuytrtqENulXwO3GGv1Bztugx6TT37NIo7imr/Qo0gsYiGtSdBa2B6fsXhTpVZDNfu1Qn3pk531e3q+nQ==} + async-mutex@0.5.0: resolution: {integrity: sha512-1A94B18jkJ3DYq284ohPxoXbfTA5HsQ7/Mf4DEhcyLx3Bz27Rh59iScbB6EPiP+B+joue6YCxcMXSbFC1tZKwA==} @@ -3093,6 +3114,9 @@ packages: resolution: {integrity: sha512-BLrgEcRTwX2o6gGxGOCNyMvGSp35YofuYzw9h1IMTRmKqttAZZVU67bdb9Pr2vUHA8+j3i2tJfjO6C6+4myGTA==} engines: {node: 18 || 20 || >=22} + base64-js@1.5.1: + resolution: {integrity: sha512-AKpaYlHn8t4SVbOHCy+b5+KKgvR4vrsD8vbvrbiQJps7fKDTkjkDry6ji0rUJjC0kzbNePLwzxq8iypo41qeWA==} + before-after-hook@4.0.0: resolution: {integrity: sha512-q6tR3RPqIB1pMiTRMFcZwuG5T8vwp+vUvEG0vuI6B+Rikh5BfPp2fQ82c925FOs+b0lcFQ8CFrL+KbilfZFhOQ==} @@ -3128,6 +3152,9 @@ packages: resolution: {integrity: sha512-Db1SbgBS/fg/392AblrMJk97KggmvYhr4pB5ZIMTWtaivCPMWLkmb7m21cJvpvgK+J3nsU2CmmixNBZx4vFj/w==} engines: {node: '>=8.0.0'} + buffer@6.0.3: + resolution: {integrity: sha512-FTiCpNxtwiZZHEZbcbTIcZjERVICn9yq/pDFkTl95/AxzD1naBctN7YO68riM/gLSDY7sdrMby8hofADYuuqOA==} + cacheable-lookup@7.0.0: resolution: {integrity: sha512-+qJyx4xiKra8mZrcwhjMRMUhD5NR1R8esPkzIYxX96JiecFoxAXFuz/GpR3+ev4PE1WamHip78wV0vcmPQtp8w==} engines: {node: '>=14.16'} @@ -3228,6 +3255,9 @@ packages: class-variance-authority@0.7.1: resolution: {integrity: sha512-Ka+9Trutv7G8M6WT6SeiRWz792K5qEqIGEGzXKhAE6xOWAY6pPH8U+9IY3oCMv6kqTmLsv7Xh/2w2RigkePMsg==} + clean-git-ref@2.0.1: + resolution: {integrity: sha512-bLSptAy2P0s6hU4PzuIMKmMJJSE6gLXGH1cntDu7bWJUksvuM+7ReOK61mozULErYvP6a15rnYl0zFDef+pyPw==} + clean-stack@2.2.0: resolution: {integrity: sha512-4diC9HaTE+KRAMWhDhrGOECgWZxoevMc5TlkObMqNSsVU62PYzXZ/SMTjzyGAFF1YusgxGcSWTEXBhp0CPwQ1A==} engines: {node: '>=6'} @@ -3411,6 +3441,11 @@ packages: typescript: optional: true + crc-32@1.2.2: + resolution: {integrity: sha512-ROmzCKrTnOwybPcJApAA6WBWij23HVfGVNKqqrZpuyZOHqK2CwHSvpGuyt/UNNvaIjEd8X5IFGp4Mh+Ie1IHJQ==} + engines: {node: '>=0.8'} + hasBin: true + cross-spawn@7.0.6: resolution: {integrity: sha512-uV2QOWP2nWzsy2aMp8aRibhi9dlzF5Hgh5SHaB9OiTGEyDTiJJyx0uy51QXdyWbtAHNua4XJzUKca3OzKUd3vA==} engines: {node: '>= 8'} @@ -3529,6 +3564,9 @@ packages: devlop@1.1.0: resolution: {integrity: sha512-RWmIqhcFf1lRYBvNmr7qTNuyCt/7/ns2jbpp1+PalgE/rDQcBT0fioSMUpJ93irlUhC5hrg4cYqe6U+0ImW0rA==} + diff3@0.0.3: + resolution: {integrity: sha512-iSq8ngPOt0K53A6eVr4d5Kn6GNrM2nQZtC740pzIriHtn4pOQ2lyzEXQMBeVcWERN0ye7fhBsk9PbLLQOnUx/g==} + diff@8.0.4: resolution: {integrity: sha512-DPi0FmjiSU5EvQV0++GFDOJ9ASQUVFh5kD+OzOnYdi7n3Wpm9hWWGfB/O2blfHcMVTL5WkQXSnRiK9makhrcnw==} engines: {node: '>=0.3.1'} @@ -3781,6 +3819,14 @@ packages: resolution: {integrity: sha512-kVscqXk4OCp68SZ0dkgEKVi6/8ij300KBWTJq32P/dYeWTSwK41WyTxalN1eRmA5Z9UU/LX9D7FWSmV9SAYx6g==} engines: {node: '>=0.10.0'} + event-target-shim@5.0.1: + resolution: {integrity: sha512-i/2XbnSz/uxRCU6+NdVJgKWDTM427+MqYbkQzD321DuCQJUqOuJKIA0IM2+W2xtYHdKOmZ4dR6fExsd4SXL+WQ==} + engines: {node: '>=6'} + + events@3.3.0: + resolution: {integrity: sha512-mQw+2fkQbALzQ7V0MY0IqdnXNOeTtP4r0lN9z7AAawCXgqea7bDii20AYrIBrFd/Hx0M2Ocz6S111CaFkUcb0Q==} + engines: {node: '>=0.8.x'} + execa@5.1.1: resolution: {integrity: sha512-8uSpZZocAZRBAPIEINJj3Lo9HyGitllczc27Eh5YYojjMFMn8yHMDMaUHE2Jqfq05D/wucwI4JGURyXt1vchyg==} engines: {node: '>=10'} @@ -4368,6 +4414,9 @@ packages: resolution: {integrity: sha512-im9DjEDQ55s9fL4EYzOAv0yMqmMBSZp6G0VvFyTMPKWxiSBHUj9NW/qqLmXUwXrrM7AvqSlTCfvqRb0cM8yYqw==} engines: {node: '>=0.10.0'} + ieee754@1.2.1: + resolution: {integrity: sha512-dcyqhDvX1C46lXZcVqCpK+FtMRQVdIMN6/Df5js2zouUsqG7I6sFxitIC+7KYK29KdXOLHdu9zL4sFnoVQnqaA==} + ignore@5.3.2: resolution: {integrity: sha512-hsBTNUqQTDwkWtcdYI2i06Y/nUBEsNEDJKjWdigLvegy8kDuJAS8uRlpkkcQpyEXL0Z/pjDy5HBmMjRCJ2gq+g==} engines: {node: '>= 4'} @@ -4624,6 +4673,11 @@ packages: isexe@2.0.0: resolution: {integrity: sha512-RHxMLp9lnKHGHRng9QFhRCMbYAcVpn69smSGcq3f36xjgVVWThj4qqLbTLlq7Ssj8B+fIQ1EuCEGI2lKsyQeIw==} + isomorphic-git@1.44.2: + resolution: {integrity: sha512-RORfhiNxaHRkGXvTiIW1SU6ngH/jW7UnKndvIToGxrGja7s317CUcZ5chENyinnyCPz7Z4vAefA75Ty1Zsyc8w==} + engines: {node: '>=14.17'} + hasBin: true + issue-parser@7.0.2: resolution: {integrity: sha512-7atWPjhGEIX3JEtMrOYd8TKzboYlq+5sNbdl9POiLYOI14G5HZiQbZP0Xj5EZdrufQVXfJlpTV0hys0CuxwxZw==} engines: {node: ^18.17 || >=20.6.1} @@ -5242,6 +5296,9 @@ packages: minimist@1.2.8: resolution: {integrity: sha512-2yyAR8qBkN3YuheJanUpWC5U3bb5osDywNB8RzDVlDwDHbocAJveqqj1u8+SVD7jkWT4yvsHCpWqqWqAxb0zCA==} + minimisted@2.0.1: + resolution: {integrity: sha512-1oPjfuLQa2caorJUM8HV8lGgWCc0qqAO1MNv/k05G4qslmsndV/5WdNZrqCiyqiz3wohia2Ij2B7w2Dr7/IyrA==} + minipass@7.1.3: resolution: {integrity: sha512-tEBHqDnIoM/1rXME1zgka9g6Q2lcoCkxHLuc7ODJ5BxbP5d4c2Z5cGgtXAku59200Cx7diuHTOYfSBD8n6mm8A==} engines: {node: '>=16 || 14 >=14.17'} @@ -5481,6 +5538,9 @@ packages: engines: {node: '>=18.0.0'} hasBin: true + once@1.4.0: + resolution: {integrity: sha512-lNaJgI+2Q5URQBkccEKHTQOPaXdUxnZZElQTZY0MFUAuaEqe1E+Nyvgdz/aIyNi6Z9MzO5dv1H8n58/GELp3+w==} + onetime@5.1.2: resolution: {integrity: sha512-kbpaSSGJTWdAY5KPVeMOKXSrPtr8C8C7wodJbcsd51jRnmD+GZu8Y0VoU6Dm5Z4vWr0Ig/1NKuWRKf7j5aaYSg==} engines: {node: '>=6'} @@ -5585,6 +5645,9 @@ packages: package-json-from-dist@1.0.1: resolution: {integrity: sha512-UEZIS3/by4OC8vL3P2dTXRETpebLI2NiI5vIrjaD/5UtrkFX/tNbwjTSRAGC/+7CAo2pIcBaRgWmcBBHcsaCIw==} + pako@1.0.11: + resolution: {integrity: sha512-4hLB8Py4zZce5s4yd9XzopqwVv/yGNhV1Bl8NTmCq1763HeK2+EwVTv+leGeL13Dnh2wfbqowVPXCIO0z4taYw==} + param-case@3.0.4: resolution: {integrity: sha512-RXlj7zCYokReqWpOPH9oYivUzLYZ5vAPIfEmCTNViosC78F8F0H9y7T7gG2M39ymgutxF5gcFEsyZQSph9Bp3A==} @@ -5681,6 +5744,10 @@ packages: resolution: {integrity: sha512-C3FsVNH1udSEX48gGX1xfvwTWfsYWj5U+8/uK15BGzIGrKoUpghX8hWZwa/OFnakBiiVNmBvemTJR5mcy7iPcg==} engines: {node: '>=4'} + pify@4.0.1: + resolution: {integrity: sha512-uB80kBFb/tfd68bVleG9T5GGsGPjJrLAUpR5PZIrhBnIaRTQRjqdJSsIKkOP6OAIFbj7GOrcudc5pNjZ+geV2g==} + engines: {node: '>=6'} + pkg-conf@2.1.0: resolution: {integrity: sha512-C+VUP+8jis7EsQZIhDYmS5qlNtjv2yP4SNtjXK9AP1ZcTRlnSfuumaTnRfYZnYgUUYVIKqL0fRvmUGDV2fmp6g==} engines: {node: '>=4'} @@ -5725,6 +5792,10 @@ packages: resolution: {integrity: sha512-JOnOPQ/8TZgjs1JIH/m9ni7FfimjNa/PRx7y/Wb5qdItsnhO0jE4AT7fC0HjC28DUQWDr50dwSYZLdRMlqDq3Q==} engines: {node: '>=8'} + process@0.11.10: + resolution: {integrity: sha512-cdGef/drWFoydD1JsMzuFf8100nZl+GT+yacc2bEced5f9Rjk4z+WtFUTBu9PhOi9j/jfmBPu0mMEY4wIdAF8A==} + engines: {node: '>= 0.6.0'} + propagate@2.0.1: resolution: {integrity: sha512-vGrhOavPSTz4QVNuBNdcNXePNdNMaO1xj9yBeH1ScQPjk/rhg9sSlCXPhMkFuaNNW/syTvYqsnbIJxMBfRbbag==} engines: {node: '>= 8'} @@ -5812,6 +5883,10 @@ packages: readable-stream@2.3.8: resolution: {integrity: sha512-8p0AUk4XODgIewSi0l8Epjs+EVnWiK7NoDIEGU0HhE7+ZyY8D1IMY7odu5lRrFXGg71L15KG8QrPmum45RTtdA==} + readable-stream@4.8.0: + resolution: {integrity: sha512-2jhTTU3Eilp/2DMT71gbcoqC35eQHasMJH0RHkYxwlxIv3+FR6Y5xAFZIwEGLxKkjZYEFlsiMNIdwUqjK2lLHQ==} + engines: {node: ^12.22.0 || ^14.17.0 || >=16.0.0} + readdirp@4.1.2: resolution: {integrity: sha512-GDhwkLfywWL2s6vEjyhri+eXmfH6j1L7JE27WhqLeYzoh/A3DBaYGEj2H/HFZCn/kMfim73FXxEJTw06WtxQwg==} engines: {node: '>= 14.18.0'} @@ -6022,6 +6097,11 @@ packages: resolution: {integrity: sha512-RJRdvCo6IAnPdsvP/7m6bsQqNnn1FCBX5ZNtFL98MmFF/4xAIJTIg1YbHW5DC2W5SKZanrC6i4HsJqlajw/dZw==} engines: {node: '>= 0.4'} + sha.js@2.4.12: + resolution: {integrity: sha512-8LzC5+bvI45BjpfXU8V5fdU2mfeKiQe1D1gIMn7XUlF3OTUrpdJpPPH4EMAnF0DsHHdSZqCdSss5qCmJKuiO3w==} + engines: {node: '>= 0.10'} + hasBin: true + shebang-command@2.0.0: resolution: {integrity: sha512-kHxr2zZpYtdmrN1qDjrrX/Z1rR1kG8Dx+gkpK1G4eXmvXswmcE1hTWBWYUzlraYw1/yZp6YuDY77YtvbN0dmDA==} engines: {node: '>=8'} @@ -6061,6 +6141,12 @@ packages: resolution: {integrity: sha512-iuh+gPf28RkltuJC7W5MRi6XAjTDCAPC/prJUpQoG4vIP3MJZ+GTydVnodXA7pwvTKb2cA0m9OFZW/cdWy/I/w==} engines: {node: '>=6'} + simple-concat@1.0.1: + resolution: {integrity: sha512-cSFtAPtRhljv69IK0hTVZQ+OfE9nePi/rtJmw5UjHeVyVroEqJXP1sFztKUy1qU+xvz3u/sfYJLa947b7nAN2Q==} + + simple-get@4.0.1: + resolution: {integrity: sha512-brv7p5WgH0jmQJr1ZDDfKDOSeWWg+OVypG99A/5vYGPqJ6pxiaHLy8nxtFjBA7oMa01ebA9gfh1uMCFqOuXxvA==} + simple-git@3.36.0: resolution: {integrity: sha512-cGQjLjK8bxJw4QuYT7gxHw3/IouVESbhahSsHrX97MzCL1gu2u7oy38W6L2ZIGECEfIBG4BabsWDPjBxJENv9Q==} @@ -6178,6 +6264,9 @@ packages: string_decoder@1.1.1: resolution: {integrity: sha512-n/ShnvDi6FHbbVfviro+WojiFzv+s8MPMHBczVePfUpDJLwoLT0ht1l4YwBCbi8pJAveEEdnkHyPyTP/mzRfwg==} + string_decoder@1.3.0: + resolution: {integrity: sha512-hkRX8U1WjJFd8LsDJ2yQ/wWWxaopEsABU1XfkM8A+j0+85JAGppt16cr1Whg6KIbb4okU6Mql6BOj+uup/wKeA==} + stringify-entities@4.0.4: resolution: {integrity: sha512-IwfBptatlO+QCJUo19AqvrPNqlVMpW9YEL2LIVY+Rpv2qsjCGxaDLNRgeGsQWJhfItebuJhsGSLjaBbNSQ+ieg==} @@ -6318,6 +6407,10 @@ packages: resolution: {integrity: sha512-voyz6MApa1rQGUxT3E+BK7/ROe8itEx7vD8/HEvt4xwXucvQ5G5oeEiHkmHZJuBO21RpOf+YYm9MOivj709jow==} engines: {node: '>=14.14'} + to-buffer@1.2.2: + resolution: {integrity: sha512-db0E3UJjcFhpDhAF4tLo03oli3pwl3dbnzXOUIlRKrp+ldk/VUxzpWYZENsw2SZiuBjHAk7DfB0VU7NKdpb6sw==} + engines: {node: '>= 0.4'} + to-regex-range@5.0.1: resolution: {integrity: sha512-65P7iz6X5yEr1cwcgvQxbbIw7Uk3gOy5dIdtZ4rDveLqhrdJP+Li/Hx6tyK0NEb+2GCyneCMJiGqrADCSNk8sQ==} engines: {node: '>=8.0'} @@ -6721,6 +6814,9 @@ packages: resolution: {integrity: sha512-42AtmgqjV+X1VpdOfyTGOYRi0/zsoLqtXQckTmqTeybT+BDIbM/Guxo7x3pE2vtpr1ok6xRqM9OpBe+Jyoqyww==} engines: {node: '>=18'} + wrappy@1.0.2: + resolution: {integrity: sha512-l4Sp/DRseor9wL6EvV2+TuQn63dMkPjZ/sp9XkghTEbV9KlPS1xUsZ3u7/IQO4wxtcFB4bgpQPRcR3QCvezPcQ==} + write-file-atomic@3.0.3: resolution: {integrity: sha512-AvHcyZ5JnSfq3ioSyjrBkH9yW4m7Ayk8/9My/DD9onKeu/94fwrMocemO2QAJFAlnnDN+ZDS+ZjAR5ua1/PV/Q==} @@ -9871,6 +9967,10 @@ snapshots: '@yuku-toolchain/types@0.9.5': {} + abort-controller@3.0.0: + dependencies: + event-target-shim: 5.0.1 + acorn-jsx@5.3.2(acorn@8.16.0): dependencies: acorn: 8.16.0 @@ -10015,6 +10115,8 @@ snapshots: async-function@1.0.0: {} + async-lock@1.4.1: {} + async-mutex@0.5.0: dependencies: tslib: 2.8.1 @@ -10054,6 +10156,8 @@ snapshots: balanced-match@4.0.4: {} + base64-js@1.5.1: {} + before-after-hook@4.0.0: {} bottleneck@2.19.5: {} @@ -10088,6 +10192,11 @@ snapshots: buffer-crc32@1.0.0: {} + buffer@6.0.3: + dependencies: + base64-js: 1.5.1 + ieee754: 1.2.1 + cacheable-lookup@7.0.0: {} cacheable-request@10.2.14: @@ -10211,6 +10320,8 @@ snapshots: dependencies: clsx: 2.1.1 + clean-git-ref@2.0.1: {} + clean-stack@2.2.0: {} clean-stack@3.0.1: @@ -10388,6 +10499,8 @@ snapshots: optionalDependencies: typescript: '@typescript/typescript6@6.0.2' + crc-32@1.2.2: {} + cross-spawn@7.0.6: dependencies: path-key: 3.1.1 @@ -10488,6 +10601,8 @@ snapshots: dependencies: dequal: 2.0.3 + diff3@0.0.3: {} + diff@8.0.4: {} dir-glob@3.0.1: @@ -10880,6 +10995,10 @@ snapshots: esutils@2.0.3: {} + event-target-shim@5.0.1: {} + + events@3.3.0: {} + execa@5.1.1: dependencies: cross-spawn: 7.0.6 @@ -11563,6 +11682,8 @@ snapshots: dependencies: safer-buffer: 2.1.2 + ieee754@1.2.1: {} + ignore@5.3.2: {} ignore@7.0.5: {} @@ -11772,6 +11893,20 @@ snapshots: isexe@2.0.0: {} + isomorphic-git@1.44.2: + dependencies: + async-lock: 1.4.1 + clean-git-ref: 2.0.1 + crc-32: 1.2.2 + diff3: 0.0.3 + ignore: 5.3.2 + minimisted: 2.0.1 + pako: 1.0.11 + pify: 4.0.1 + readable-stream: 4.8.0 + sha.js: 2.4.12 + simple-get: 4.0.1 + issue-parser@7.0.2: dependencies: lodash.capitalize: 4.2.1 @@ -12615,6 +12750,10 @@ snapshots: minimist@1.2.8: {} + minimisted@2.0.1: + dependencies: + minimist: 1.2.8 + minipass@7.1.3: {} mocha@11.7.5: @@ -12857,6 +12996,10 @@ snapshots: - aws-crt - supports-color + once@1.4.0: + dependencies: + wrappy: 1.0.2 + onetime@5.1.2: dependencies: mimic-fn: 2.1.0 @@ -12959,6 +13102,8 @@ snapshots: package-json-from-dist@1.0.1: {} + pako@1.0.11: {} + param-case@3.0.4: dependencies: dot-case: 3.0.4 @@ -13051,6 +13196,8 @@ snapshots: pify@3.0.0: {} + pify@4.0.1: {} + pkg-conf@2.1.0: dependencies: find-up: 2.1.0 @@ -13088,6 +13235,8 @@ snapshots: dependencies: fromentries: 1.3.2 + process@0.11.10: {} + propagate@2.0.1: {} property-information@7.2.0: {} @@ -13183,6 +13332,14 @@ snapshots: string_decoder: 1.1.1 util-deprecate: 1.0.2 + readable-stream@4.8.0: + dependencies: + abort-controller: 3.0.0 + buffer: 6.0.3 + events: 3.3.0 + process: 0.11.10 + string_decoder: 1.3.0 + readdirp@4.1.2: {} readdirp@5.1.1: {} @@ -13499,6 +13656,12 @@ snapshots: es-errors: 1.3.0 es-object-atoms: 1.1.1 + sha.js@2.4.12: + dependencies: + inherits: 2.0.4 + safe-buffer: 5.2.1 + to-buffer: 1.2.2 + shebang-command@2.0.0: dependencies: shebang-regex: 3.0.0 @@ -13554,6 +13717,14 @@ snapshots: figures: 2.0.0 pkg-conf: 2.1.0 + simple-concat@1.0.1: {} + + simple-get@4.0.1: + dependencies: + decompress-response: 6.0.0 + once: 1.4.0 + simple-concat: 1.0.1 + simple-git@3.36.0: dependencies: '@kwsites/file-exists': 1.1.1 @@ -13703,6 +13874,10 @@ snapshots: dependencies: safe-buffer: 5.1.2 + string_decoder@1.3.0: + dependencies: + safe-buffer: 5.2.1 + stringify-entities@4.0.4: dependencies: character-entities-html4: 2.1.0 @@ -13830,6 +14005,12 @@ snapshots: tmp@0.2.5: {} + to-buffer@1.2.2: + dependencies: + isarray: 2.0.5 + safe-buffer: 5.2.1 + typed-array-buffer: 1.0.3 + to-regex-range@5.0.1: dependencies: is-number: 7.0.0 @@ -14209,6 +14390,8 @@ snapshots: string-width: 7.2.0 strip-ansi: 7.2.0 + wrappy@1.0.2: {} + write-file-atomic@3.0.3: dependencies: imurmurhash: 0.1.4 diff --git a/src/infrastructure/pristine-store-service.ts b/src/infrastructure/pristine-store-service.ts new file mode 100644 index 000000000..c7b013989 --- /dev/null +++ b/src/infrastructure/pristine-store-service.ts @@ -0,0 +1,223 @@ +import fs from 'node:fs'; +import git, { TreeEntry } from 'isomorphic-git'; +import { err, ok, Result } from 'neverthrow'; +import { DirectoryPath } from '../types/file/directoryPath.js'; +import { FileName } from '../types/file/fileName.js'; +import { FilePath } from '../types/file/filePath.js'; +import { CommitId } from '../types/sdk/commit-id.js'; +import { describeGeneration, GenerationRecord, SdkGeneration } from '../types/sdk/generation-record.js'; +import { SdkRepository } from '../types/sdk/sdk-repository.js'; +import { SnapshotFiles } from '../types/sdk/snapshot-files.js'; +import { SnapshotRef } from '../types/sdk/snapshot-ref.js'; +import { errorMessage } from '../utils/error-utils.js'; +import { FileService } from './file-service.js'; + +export type PristineStoreProblem = { + kind: 'nestedSdkDirectory' | 'snapshotMissing' | 'gitFailed'; + reason: string; +}; + +const AUTHOR = { name: 'APIMatic CLI', email: 'developer@apimatic.io' } as const; +const GIT_DIRECTORY = '.git'; +const RECORD_DIRECTORY = '.apimatic'; +const RECORD_FILE = 'generation.json'; +const BLOB_MODE = '100644'; +const TREE_MODE = '040000'; + +/** + * The snapshots behind custom code, kept as commits under hidden refs of the repository that + * encloses an SDK directory: the pristine chain (one commit per generation, the base of the + * next merge) and the previous state (the directory before the last run wrote to it). The + * repository is never pushed, fetched, or checked out from here. + */ +export class PristineStoreService { + private readonly fileService = new FileService(); + + public async findRepository(sdkDirectory: DirectoryPath): Promise { + try { + const root = await git.findRoot({ fs, filepath: sdkDirectory.toString() }); + return SdkRepository.create(new DirectoryPath(root), sdkDirectory); + } catch { + return undefined; + } + } + + public async initRepository(sdkDirectory: DirectoryPath): Promise> { + try { + await git.init({ fs, dir: sdkDirectory.toString(), defaultBranch: 'main' }); + const repository = SdkRepository.create(sdkDirectory, sdkDirectory); + return repository === undefined ? err(gitFailed('the repository could not be opened')) : ok(repository); + } catch (error) { + return err(gitFailed(errorMessage(error))); + } + } + + /** The newest pristine snapshot of the SDK directory, or none when nothing was recorded there. */ + public async pristineTip(repository: SdkRepository): Promise { + return this.resolve(repository, repository.pristineRef()); + } + + /** Records `generated`, a freshly generated SDK, as the next pristine snapshot of the SDK directory. */ + public async recordPristine( + repository: SdkRepository, + generated: DirectoryPath, + generation: SdkGeneration + ): Promise> { + const ref = repository.pristineRef(); + const colliding = (await this.pristineRefsIn(repository)).find((existing) => existing.collidesWith(ref)); + if (colliding !== undefined) { + return err({ + kind: 'nestedSdkDirectory', + reason: `${ref} cannot be kept beside ${colliding}: one SDK directory sits inside another` + }); + } + try { + const parent = await this.resolve(repository, ref); + const tree = await this.treeOf(repository, generated, []); + const commit = await this.commit( + repository, + tree, + parent, + `apimatic: pristine ${describeGeneration(generation)}` + ); + await git.writeRef({ + fs, + dir: repository.root().toString(), + ref: ref.toString(), + value: commit.toString(), + force: true + }); + return ok(commit); + } catch (error) { + return err(gitFailed(errorMessage(error))); + } + } + + /** Keeps the SDK directory as it is on disk, replacing what the last run kept, so a merge can be undone. */ + public async savePrevious(repository: SdkRepository): Promise> { + try { + const excluded = repository.isRootSdk() ? [GIT_DIRECTORY] : []; + const tree = await this.treeOf(repository, repository.sdkDirectory(), excluded); + const commit = await this.commit(repository, tree, undefined, 'apimatic: before regeneration'); + await git.writeRef({ + fs, + dir: repository.root().toString(), + ref: repository.previousRef().toString(), + value: commit.toString(), + force: true + }); + return ok(commit); + } catch (error) { + return err(gitFailed(errorMessage(error))); + } + } + + public async readSnapshot( + repository: SdkRepository, + commit: CommitId + ): Promise> { + const dir = repository.root().toString(); + try { + const { commit: object } = await git.readCommit({ fs, dir, oid: commit.toString() }); + const files = new Map(); + await this.collectFiles(dir, object.tree, '', files); + return ok(files); + } catch (error) { + if (error instanceof git.Errors.NotFoundError) { + return err({ kind: 'snapshotMissing', reason: `commit ${commit} is not in the repository` }); + } + return err(gitFailed(errorMessage(error))); + } + } + + public async readRecord(sdkDirectory: DirectoryPath): Promise { + const file = this.recordFile(sdkDirectory); + if (!(await this.fileService.fileExists(file))) { + return undefined; + } + return GenerationRecord.parse(await this.fileService.getContents(file)); + } + + public async writeRecord(sdkDirectory: DirectoryPath, record: GenerationRecord): Promise { + const file = this.recordFile(sdkDirectory); + await this.fileService.replaceContents(file, record.toJson()); + return file; + } + + private recordFile(sdkDirectory: DirectoryPath): FilePath { + return new FilePath(sdkDirectory.join(RECORD_DIRECTORY), new FileName(RECORD_FILE)); + } + + private async resolve(repository: SdkRepository, ref: SnapshotRef): Promise { + try { + return CommitId.fromGit(await git.resolveRef({ fs, dir: repository.root().toString(), ref: ref.toString() })); + } catch { + return undefined; + } + } + + // The root ref and the refs below it: git keeps each as a file, so a listing of the + // namespace fails when the namespace itself is a ref, and both have to be asked for. + private async pristineRefsIn(repository: SdkRepository): Promise { + const dir = repository.root().toString(); + const namespace = SnapshotRef.pristineNamespace(); + const root = + (await this.resolve(repository, SnapshotRef.pristine(''))) === undefined ? [] : [SnapshotRef.pristine('')]; + const below = await git.listRefs({ fs, dir, filepath: namespace }).catch((): string[] => []); + return [...root, ...below.map((sdkPath) => SnapshotRef.pristine(sdkPath))]; + } + + private async commit( + repository: SdkRepository, + tree: string, + parent: CommitId | undefined, + message: string + ): Promise { + const signature = { ...AUTHOR, timestamp: Math.floor(Date.now() / 1000), timezoneOffset: 0 }; + const oid = await git.writeCommit({ + fs, + dir: repository.root().toString(), + commit: { + message, + tree, + parent: parent === undefined ? [] : [parent.toString()], + author: signature, + committer: signature + } + }); + return CommitId.fromGit(oid); + } + + /** Writes `directory` as git objects and returns the tree's id; an empty directory is an empty tree, as git has no other way to hold one. */ + private async treeOf(repository: SdkRepository, directory: DirectoryPath, excluded: string[]): Promise { + const dir = repository.root().toString(); + const entries: TreeEntry[] = []; + for (const entry of await fs.promises.readdir(directory.toString(), { withFileTypes: true })) { + if (excluded.includes(entry.name)) { + continue; + } + if (entry.isDirectory()) { + const oid = await this.treeOf(repository, directory.join(entry.name), []); + entries.push({ mode: TREE_MODE, path: entry.name, oid, type: 'tree' }); + } else { + const blob = await fs.promises.readFile(new FilePath(directory, new FileName(entry.name)).toString()); + const oid = await git.writeBlob({ fs, dir, blob: new Uint8Array(blob) }); + entries.push({ mode: BLOB_MODE, path: entry.name, oid, type: 'blob' }); + } + } + return git.writeTree({ fs, dir, tree: entries }); + } + + private async collectFiles(dir: string, tree: string, prefix: string, files: Map): Promise { + for (const entry of (await git.readTree({ fs, dir, oid: tree })).tree) { + const path = prefix === '' ? entry.path : `${prefix}/${entry.path}`; + if (entry.type === 'tree') { + await this.collectFiles(dir, entry.oid, path, files); + } else if (entry.type === 'blob') { + files.set(path, (await git.readBlob({ fs, dir, oid: entry.oid })).blob); + } + } + } +} + +const gitFailed = (reason: string): PristineStoreProblem => ({ kind: 'gitFailed', reason }); diff --git a/src/types/sdk/commit-id.ts b/src/types/sdk/commit-id.ts new file mode 100644 index 000000000..13c2ca007 --- /dev/null +++ b/src/types/sdk/commit-id.ts @@ -0,0 +1,25 @@ +export class CommitId { + private readonly sha: string; + + private constructor(sha: string) { + this.sha = sha; + } + + /** From a record or user input, which may hold anything. */ + public static create(sha: string): CommitId | undefined { + return /^[0-9a-f]{40}$/.test(sha) ? new CommitId(sha) : undefined; + } + + /** From git itself, whose ids need no check. */ + public static fromGit(sha: string): CommitId { + return new CommitId(sha); + } + + public isEqual(other: CommitId): boolean { + return this.sha === other.sha; + } + + public toString(): string { + return this.sha; + } +} diff --git a/src/types/sdk/generation-record.ts b/src/types/sdk/generation-record.ts new file mode 100644 index 000000000..0a0e33d80 --- /dev/null +++ b/src/types/sdk/generation-record.ts @@ -0,0 +1,133 @@ +import { CommitId } from './commit-id.js'; +import { CodeGenerationVersion, Language, Stability } from './generate.js'; + +/** What was generated: a snapshot is a base only for the same generator output. */ +export type SdkGeneration = { + language: Language; + codegenVersion: CodeGenerationVersion; + stability: Stability; +}; + +const SCHEMA_VERSION = 1; + +const isOneOf = (values: readonly T[], value: unknown): value is T => + typeof value === 'string' && (values as readonly string[]).includes(value); + +const isStringArray = (value: unknown): value is string[] => + Array.isArray(value) && value.every((item) => typeof item === 'string'); + +/** + * What `.apimatic/generation.json` records: the pristine snapshot an SDK directory was last + * written from, and the files a run left with conflict markers. Committed with the SDK, so a + * branch or a clone merges against the base it was generated from. + */ +export class GenerationRecord { + private readonly generation: SdkGeneration; + private readonly pristine: CommitId; + private readonly generatedAt: Date; + private readonly cliVersion: string; + private readonly conflicts: readonly string[]; + + private constructor( + generation: SdkGeneration, + pristine: CommitId, + generatedAt: Date, + cliVersion: string, + conflicts: readonly string[] + ) { + this.generation = generation; + this.pristine = pristine; + this.generatedAt = generatedAt; + this.cliVersion = cliVersion; + this.conflicts = conflicts; + } + + /** Written by a run that just recorded `pristine`; `conflicts` are paths below the SDK directory with `/` between segments. */ + public static written( + generation: SdkGeneration, + pristine: CommitId, + generatedAt: Date, + cliVersion: string, + conflicts: readonly string[] = [] + ): GenerationRecord { + return new GenerationRecord(generation, pristine, generatedAt, cliVersion, conflicts); + } + + public static parse(json: string): GenerationRecord | undefined { + let parsed: unknown; + try { + parsed = JSON.parse(json); + } catch { + return undefined; + } + if (typeof parsed !== 'object' || parsed === null) { + return undefined; + } + const record = parsed as Record; + const pristine = typeof record.pristine === 'string' ? CommitId.create(record.pristine) : undefined; + const generatedAt = typeof record.generatedAt === 'string' ? new Date(record.generatedAt) : undefined; + if ( + record.schemaVersion !== SCHEMA_VERSION || + !isOneOf(Object.values(Language), record.language) || + !isOneOf(Object.values(CodeGenerationVersion), record.codegenVersion) || + !isOneOf(Object.values(Stability), record.stability) || + pristine === undefined || + generatedAt === undefined || + Number.isNaN(generatedAt.getTime()) || + typeof record.cliVersion !== 'string' || + !isStringArray(record.conflicts) + ) { + return undefined; + } + return new GenerationRecord( + { language: record.language, codegenVersion: record.codegenVersion, stability: record.stability }, + pristine, + generatedAt, + record.cliVersion, + record.conflicts + ); + } + + public pristineCommit(): CommitId { + return this.pristine; + } + + public conflictedPaths(): readonly string[] { + return this.conflicts; + } + + public hasConflicts(): boolean { + return this.conflicts.length > 0; + } + + /** Whether `generation` would produce the same kind of output the recorded snapshot holds. */ + public isFor(generation: SdkGeneration): boolean { + return ( + this.generation.language === generation.language && + this.generation.codegenVersion === generation.codegenVersion && + this.generation.stability === generation.stability + ); + } + + public toJson(): string { + return `${JSON.stringify( + { + schemaVersion: SCHEMA_VERSION, + language: this.generation.language, + codegenVersion: this.generation.codegenVersion, + stability: this.generation.stability, + pristine: this.pristine.toString(), + generatedAt: this.generatedAt.toISOString(), + cliVersion: this.cliVersion, + conflicts: this.conflicts + }, + null, + 2 + )}\n`; + } +} + +/** How a snapshot's commit message names its generation. */ +export function describeGeneration(generation: SdkGeneration): string { + return `${generation.language} ${generation.codegenVersion} ${generation.stability}`; +} diff --git a/src/types/sdk/sdk-repository.ts b/src/types/sdk/sdk-repository.ts new file mode 100644 index 000000000..7ae7ac891 --- /dev/null +++ b/src/types/sdk/sdk-repository.ts @@ -0,0 +1,43 @@ +import path from 'node:path'; +import { DirectoryPath, posix } from '../file/directoryPath.js'; +import { SnapshotRef } from './snapshot-ref.js'; + +/** An SDK directory together with the git repository that encloses it, which is where its snapshots live. */ +export class SdkRepository { + private readonly rootDirectory: DirectoryPath; + private readonly sdk: DirectoryPath; + + private constructor(root: DirectoryPath, sdkDirectory: DirectoryPath) { + this.rootDirectory = root; + this.sdk = sdkDirectory; + } + + public static create(root: DirectoryPath, sdkDirectory: DirectoryPath): SdkRepository | undefined { + return root.contains(sdkDirectory) ? new SdkRepository(root, sdkDirectory) : undefined; + } + + public root(): DirectoryPath { + return this.rootDirectory; + } + + public sdkDirectory(): DirectoryPath { + return this.sdk; + } + + /** True when the SDK directory is the repository itself, so its `.git` sits among the SDK files. */ + public isRootSdk(): boolean { + return this.rootDirectory.isEqual(this.sdk); + } + + public pristineRef(): SnapshotRef { + return SnapshotRef.pristine(this.sdkPath()); + } + + public previousRef(): SnapshotRef { + return SnapshotRef.previous(this.sdkPath()); + } + + private sdkPath(): string { + return posix(path.relative(this.rootDirectory.toString(), this.sdk.toString())); + } +} diff --git a/src/types/sdk/snapshot-files.ts b/src/types/sdk/snapshot-files.ts new file mode 100644 index 000000000..ece0c9d3d --- /dev/null +++ b/src/types/sdk/snapshot-files.ts @@ -0,0 +1,2 @@ +/** A snapshot's files by their path below the SDK directory, with `/` between segments, as git names them. */ +export type SnapshotFiles = ReadonlyMap; diff --git a/src/types/sdk/snapshot-ref.ts b/src/types/sdk/snapshot-ref.ts new file mode 100644 index 000000000..7af3d826f --- /dev/null +++ b/src/types/sdk/snapshot-ref.ts @@ -0,0 +1,43 @@ +const PRISTINE = 'refs/apimatic/pristine'; +const PREVIOUS = 'refs/apimatic/previous'; + +/** + * A hidden ref holding one SDK directory's snapshots: never a branch, never checked out, and + * named after the directory's path from the repository root so each SDK directory has its own. + */ +export class SnapshotRef { + private readonly name: string; + + private constructor(name: string) { + this.name = name; + } + + /** `sdkPath` is the SDK directory below the repository root with `/` between its segments, or '' for the root itself. */ + public static pristine(sdkPath: string): SnapshotRef { + return new SnapshotRef(sdkPath === '' ? PRISTINE : `${PRISTINE}/${sdkPath}`); + } + + public static previous(sdkPath: string): SnapshotRef { + return new SnapshotRef(sdkPath === '' ? PREVIOUS : `${PREVIOUS}/${sdkPath}`); + } + + /** Where git keeps every pristine ref, for listing them. */ + public static pristineNamespace(): string { + return PRISTINE; + } + + /** Git stores refs as files in directories, so a ref cannot sit on another ref's path. */ + public collidesWith(other: SnapshotRef): boolean { + return ( + this.name !== other.name && (other.name.startsWith(`${this.name}/`) || this.name.startsWith(`${other.name}/`)) + ); + } + + public isEqual(other: SnapshotRef): boolean { + return this.name === other.name; + } + + public toString(): string { + return this.name; + } +} diff --git a/test/infrastructure/pristine-store-service.test.ts b/test/infrastructure/pristine-store-service.test.ts new file mode 100644 index 000000000..23d85f9fc --- /dev/null +++ b/test/infrastructure/pristine-store-service.test.ts @@ -0,0 +1,225 @@ +import fs from 'fs'; +import os from 'os'; +import path from 'path'; +import { TextDecoder } from 'node:util'; +import { expect } from 'chai'; +import git from 'isomorphic-git'; +import { PristineStoreService } from '../../src/infrastructure/pristine-store-service.js'; +import { DirectoryPath } from '../../src/types/file/directoryPath.js'; +import { CommitId } from '../../src/types/sdk/commit-id.js'; +import { CodeGenerationVersion, Language, Stability } from '../../src/types/sdk/generate.js'; +import { GenerationRecord, SdkGeneration } from '../../src/types/sdk/generation-record.js'; +import { SdkRepository } from '../../src/types/sdk/sdk-repository.js'; + +const TYPESCRIPT: SdkGeneration = { + language: Language.TYPESCRIPT, + codegenVersion: CodeGenerationVersion.V4, + stability: Stability.STABLE +}; + +describe('PristineStoreService', () => { + let root: string; + let service: PristineStoreService; + + const write = (directory: DirectoryPath, relative: string, contents: string) => { + const target = path.join(directory.toString(), relative); + fs.mkdirSync(path.dirname(target), { recursive: true }); + fs.writeFileSync(target, contents); + }; + + const generated = (files: Record): DirectoryPath => { + const directory = new DirectoryPath(fs.mkdtempSync(path.join(root, 'generated-'))); + for (const [relative, contents] of Object.entries(files)) { + write(directory, relative, contents); + } + return directory; + }; + + const project = async (sdk = path.join('sdk', 'typescript')): Promise => { + const projectDirectory = new DirectoryPath(path.join(root, 'project')); + fs.mkdirSync(projectDirectory.join(sdk).toString(), { recursive: true }); + await git.init({ fs, dir: projectDirectory.toString(), defaultBranch: 'main' }); + return (await service.findRepository(projectDirectory.join(sdk)))!; + }; + + const read = async (repository: SdkRepository, ref: string) => + git.resolveRef({ fs, dir: repository.root().toString(), ref }); + + const commitOf = async (repository: SdkRepository, oid: string) => + (await git.readCommit({ fs, dir: repository.root().toString(), oid })).commit; + + const text = (bytes: Uint8Array | undefined) => (bytes === undefined ? undefined : new TextDecoder().decode(bytes)); + + beforeEach(() => { + root = fs.mkdtempSync(path.join(os.tmpdir(), 'pristine-store-')); + service = new PristineStoreService(); + }); + + afterEach(() => { + fs.rmSync(root, { recursive: true, force: true }); + }); + + describe('findRepository', () => { + it('finds nothing outside a repository', async () => { + const sdk = new DirectoryPath(path.join(root, 'sdk', 'typescript')); + fs.mkdirSync(sdk.toString(), { recursive: true }); + + expect(await service.findRepository(sdk)).to.be.undefined; + }); + + it('answers with the enclosing repository, however deep the SDK directory sits', async () => { + const repository = await project(path.join('packages', 'sdk', 'typescript')); + + expect(repository.root().isEqual(new DirectoryPath(path.join(root, 'project')))).to.be.true; + expect(repository.isRootSdk()).to.be.false; + expect(repository.pristineRef().toString()).to.equal('refs/apimatic/pristine/packages/sdk/typescript'); + }); + }); + + describe('initRepository', () => { + it('makes the SDK directory its own repository, on main', async () => { + const sdk = new DirectoryPath(path.join(root, 'sdk', 'typescript')); + write(sdk, 'README.md', '# sdk'); + + const repository = (await service.initRepository(sdk))._unsafeUnwrap(); + + expect(repository.isRootSdk()).to.be.true; + expect(repository.pristineRef().toString()).to.equal('refs/apimatic/pristine'); + expect(fs.readFileSync(path.join(sdk.toString(), '.git', 'HEAD'), 'utf-8').trim()).to.equal( + 'ref: refs/heads/main' + ); + expect(fs.readFileSync(path.join(sdk.toString(), 'README.md'), 'utf-8')).to.equal('# sdk'); + }); + }); + + describe('recordPristine', () => { + it('records the first snapshot with no parent, under the SDK directory’s ref', async () => { + const repository = await project(); + + const commit = ( + await service.recordPristine( + repository, + generated({ 'README.md': '# v1', 'src/index.ts': 'export {};' }), + TYPESCRIPT + ) + )._unsafeUnwrap(); + + expect(await read(repository, 'refs/apimatic/pristine/sdk/typescript')).to.equal(commit.toString()); + const object = await commitOf(repository, commit.toString()); + expect(object.parent).to.be.empty; + expect(object.message.trim()).to.equal('apimatic: pristine typescript v4 stable'); + expect(object.author.name).to.equal('APIMatic CLI'); + const files = (await service.readSnapshot(repository, commit))._unsafeUnwrap(); + expect([...files.keys()]).to.have.members(['README.md', 'src/index.ts']); + expect(text(files.get('src/index.ts'))).to.equal('export {};'); + }); + + it('chains the next snapshot onto the last and moves the ref', async () => { + const repository = await project(); + const first = ( + await service.recordPristine(repository, generated({ 'README.md': '# v1' }), TYPESCRIPT) + )._unsafeUnwrap(); + + const second = ( + await service.recordPristine(repository, generated({ 'README.md': '# v2' }), TYPESCRIPT) + )._unsafeUnwrap(); + + expect((await commitOf(repository, second.toString())).parent).to.deep.equal([first.toString()]); + expect((await service.pristineTip(repository))?.isEqual(second)).to.be.true; + expect(text((await service.readSnapshot(repository, first))._unsafeUnwrap().get('README.md'))).to.equal('# v1'); + }); + + it('refuses an SDK directory inside one that already has snapshots, and the reverse', async () => { + const outer = await project(); + (await service.recordPristine(outer, generated({ 'README.md': '# outer' }), TYPESCRIPT))._unsafeUnwrap(); + const inner = (await service.findRepository(outer.sdkDirectory().join('typescript')))!; + + const refused = await service.recordPristine(inner, generated({ 'README.md': '# inner' }), TYPESCRIPT); + + expect(refused._unsafeUnwrapErr().kind).to.equal('nestedSdkDirectory'); + const other = await project(path.join('other', 'typescript', 'nested')); + (await service.recordPristine(other, generated({ 'README.md': '# nested' }), TYPESCRIPT))._unsafeUnwrap(); + const parent = (await service.findRepository( + new DirectoryPath(path.join(root, 'project', 'other', 'typescript')) + ))!; + expect((await service.recordPristine(parent, generated({}), TYPESCRIPT))._unsafeUnwrapErr().kind).to.equal( + 'nestedSdkDirectory' + ); + }); + + it('refuses any SDK directory below a repository whose root is one', async () => { + const sdk = new DirectoryPath(path.join(root, 'sdk')); + fs.mkdirSync(sdk.toString(), { recursive: true }); + const repository = (await service.initRepository(sdk))._unsafeUnwrap(); + (await service.recordPristine(repository, generated({ 'README.md': '# root' }), TYPESCRIPT))._unsafeUnwrap(); + expect(await read(repository, 'refs/apimatic/pristine')).to.be.a('string'); + const below = (await service.findRepository(sdk.join('typescript')))!; + + expect((await service.recordPristine(below, generated({}), TYPESCRIPT))._unsafeUnwrapErr().kind).to.equal( + 'nestedSdkDirectory' + ); + }); + }); + + describe('savePrevious', () => { + it('keeps the directory as it is, without its own .git, and replaces the last one', async () => { + const sdk = new DirectoryPath(path.join(root, 'sdk', 'typescript')); + write(sdk, 'README.md', '# edited'); + write(sdk, path.join('.apimatic', 'generation.json'), '{}'); + const repository = (await service.initRepository(sdk))._unsafeUnwrap(); + + const first = (await service.savePrevious(repository))._unsafeUnwrap(); + write(sdk, 'README.md', '# edited again'); + const second = (await service.savePrevious(repository))._unsafeUnwrap(); + + const kept = (await service.readSnapshot(repository, second))._unsafeUnwrap(); + expect([...kept.keys()]).to.have.members(['README.md', '.apimatic/generation.json']); + expect(text(kept.get('README.md'))).to.equal('# edited again'); + expect(await read(repository, 'refs/apimatic/previous')).to.equal(second.toString()); + expect((await commitOf(repository, second.toString())).parent).to.be.empty; + expect(text((await service.readSnapshot(repository, first))._unsafeUnwrap().get('README.md'))).to.equal( + '# edited' + ); + }); + }); + + describe('readSnapshot', () => { + it('reports a commit the repository does not hold', async () => { + const repository = await project(); + + const missing = await service.readSnapshot(repository, CommitId.create('0'.repeat(40))!); + + expect(missing._unsafeUnwrapErr().kind).to.equal('snapshotMissing'); + }); + }); + + describe('the generation record', () => { + it('is absent until written, then reads back what was written', async () => { + const repository = await project(); + const sdk = repository.sdkDirectory(); + expect(await service.readRecord(sdk)).to.be.undefined; + const pristine = ( + await service.recordPristine(repository, generated({ 'README.md': '# v1' }), TYPESCRIPT) + )._unsafeUnwrap(); + const record = GenerationRecord.written(TYPESCRIPT, pristine, new Date('2026-10-20T10:00:00Z'), '2.3.0', [ + 'src/index.ts' + ]); + + const file = await service.writeRecord(sdk, record); + + expect(file.toString()).to.equal(path.join(sdk.toString(), '.apimatic', 'generation.json')); + const read = (await service.readRecord(sdk))!; + expect(read.pristineCommit().isEqual(pristine)).to.be.true; + expect(read.conflictedPaths()).to.deep.equal(['src/index.ts']); + expect(read.isFor(TYPESCRIPT)).to.be.true; + expect(read.toJson()).to.equal(record.toJson()); + }); + + it('reads as absent when the file cannot be understood', async () => { + const repository = await project(); + write(repository.sdkDirectory(), path.join('.apimatic', 'generation.json'), '{"schemaVersion": 2}'); + + expect(await service.readRecord(repository.sdkDirectory())).to.be.undefined; + }); + }); +}); diff --git a/test/types/sdk/generation-record.test.ts b/test/types/sdk/generation-record.test.ts new file mode 100644 index 000000000..8525d5fc3 --- /dev/null +++ b/test/types/sdk/generation-record.test.ts @@ -0,0 +1,74 @@ +import { expect } from 'chai'; +import { CommitId } from '../../../src/types/sdk/commit-id.js'; +import { CodeGenerationVersion, Language, Stability } from '../../../src/types/sdk/generate.js'; +import { GenerationRecord } from '../../../src/types/sdk/generation-record.js'; + +const SHA = 'a'.repeat(40); + +const json = (overrides: Record = {}) => + JSON.stringify({ + schemaVersion: 1, + language: 'typescript', + codegenVersion: 'v4', + stability: 'stable', + pristine: SHA, + generatedAt: '2026-10-20T10:00:00.000Z', + cliVersion: '2.3.0', + conflicts: [], + ...overrides + }); + +describe('GenerationRecord', () => { + it('round-trips through its file form', () => { + const record = GenerationRecord.written( + { language: Language.PYTHON, codegenVersion: CodeGenerationVersion.V4, stability: Stability.BETA }, + CommitId.create(SHA)!, + new Date('2026-10-20T10:00:00Z'), + '2.3.0', + ['src/models/book.py'] + ); + + const parsed = GenerationRecord.parse(record.toJson())!; + + expect(parsed.toJson()).to.equal(record.toJson()); + expect(parsed.hasConflicts()).to.be.true; + expect( + parsed.isFor({ language: Language.PYTHON, codegenVersion: CodeGenerationVersion.V4, stability: Stability.BETA }) + ).to.be.true; + expect( + parsed.isFor({ language: Language.PYTHON, codegenVersion: CodeGenerationVersion.V4, stability: Stability.STABLE }) + ).to.be.false; + }); + + it('writes its fields in a fixed order, so a committed record diffs only when something changed', () => { + const record = GenerationRecord.parse(json())!; + + expect(record.toJson()).to.equal( + [ + '{', + ' "schemaVersion": 1,', + ' "language": "typescript",', + ' "codegenVersion": "v4",', + ' "stability": "stable",', + ` "pristine": "${SHA}",`, + ' "generatedAt": "2026-10-20T10:00:00.000Z",', + ' "cliVersion": "2.3.0",', + ' "conflicts": []', + '}', + '' + ].join('\n') + ); + }); + + it('refuses what it cannot read', () => { + expect(GenerationRecord.parse('not json')).to.be.undefined; + expect(GenerationRecord.parse('[]')).to.be.undefined; + expect(GenerationRecord.parse(json({ schemaVersion: 2 }))).to.be.undefined; + expect(GenerationRecord.parse(json({ language: 'cobol' }))).to.be.undefined; + expect(GenerationRecord.parse(json({ stability: 'alpha' }))).to.be.undefined; + expect(GenerationRecord.parse(json({ pristine: 'HEAD' }))).to.be.undefined; + expect(GenerationRecord.parse(json({ generatedAt: 'yesterday' }))).to.be.undefined; + expect(GenerationRecord.parse(json({ conflicts: 'src/index.ts' }))).to.be.undefined; + expect(GenerationRecord.parse(json({ cliVersion: 2 }))).to.be.undefined; + }); +}); diff --git a/test/types/sdk/snapshot-ref.test.ts b/test/types/sdk/snapshot-ref.test.ts new file mode 100644 index 000000000..d41c09a7f --- /dev/null +++ b/test/types/sdk/snapshot-ref.test.ts @@ -0,0 +1,21 @@ +import { expect } from 'chai'; +import { SnapshotRef } from '../../../src/types/sdk/snapshot-ref.js'; + +describe('SnapshotRef', () => { + it('names a ref after the SDK directory, and the namespace alone for a repository that is one', () => { + expect(SnapshotRef.pristine('sdk/typescript').toString()).to.equal('refs/apimatic/pristine/sdk/typescript'); + expect(SnapshotRef.previous('sdk/typescript').toString()).to.equal('refs/apimatic/previous/sdk/typescript'); + expect(SnapshotRef.pristine('').toString()).to.equal('refs/apimatic/pristine'); + }); + + it('collides only with a ref on its own path, in either direction', () => { + const typescript = SnapshotRef.pristine('sdk/typescript'); + + expect(typescript.collidesWith(SnapshotRef.pristine('sdk/typescript/typescript'))).to.be.true; + expect(typescript.collidesWith(SnapshotRef.pristine('sdk'))).to.be.true; + expect(typescript.collidesWith(SnapshotRef.pristine(''))).to.be.true; + expect(typescript.collidesWith(SnapshotRef.pristine('sdk/typescript'))).to.be.false; + expect(typescript.collidesWith(SnapshotRef.pristine('sdk/typescript-v2'))).to.be.false; + expect(typescript.collidesWith(SnapshotRef.pristine('sdk/python'))).to.be.false; + }); +}); From 185e0663d8ab03e02f6d811ad13b22fbd42fd0eb Mon Sep 17 00:00:00 2001 From: Asad Ali <14asadali@gmail.com> Date: Tue, 20 Oct 2026 12:04:47 +0500 Subject: [PATCH 02/29] feat(sdk): merge regenerated SDKs through the system git Replace the isomorphic-git pristine store with a GitService that drives the installed git (2.38 or newer) through its plumbing, and add the three-way merge of a regenerated SDK over the developer's edits: `git merge-tree --write-tree` between two temporary commits on the pristine base, its output parsed into a merge plan of writes, deletions, renames and conflicts by kind, with the markers relabelled for the developer. Every git call carries fixed settings so the developer's own configuration cannot change a snapshot or a merge. Co-Authored-By: Claude Fable 5.1 --- package.json | 1 - pnpm-lock.yaml | 172 -------- src/application/sdk-merge/markers.ts | 31 ++ src/application/sdk-merge/merge-plan.ts | 76 ++++ .../sdk-merge/merge-tree-output.ts | 36 ++ src/application/sdk-merge/sdk-merge.ts | 137 +++++++ src/infrastructure/git-service.ts | 302 ++++++++++++++ src/infrastructure/merge-service.ts | 72 ++++ src/infrastructure/pristine-store-service.ts | 195 ++++----- src/types/sdk/git-version.ts | 33 ++ src/types/sdk/tree-id.ts | 25 ++ test/application/sdk-merge/markers.test.ts | 36 ++ .../sdk-merge/merge-tree-output.test.ts | 63 +++ test/application/sdk-merge/sdk-merge.test.ts | 130 ++++++ test/infrastructure/git-service.test.ts | 161 ++++++++ test/infrastructure/merge-service.test.ts | 386 ++++++++++++++++++ .../pristine-store-service.test.ts | 110 ++--- test/types/sdk/git-version.test.ts | 18 + 18 files changed, 1641 insertions(+), 343 deletions(-) create mode 100644 src/application/sdk-merge/markers.ts create mode 100644 src/application/sdk-merge/merge-plan.ts create mode 100644 src/application/sdk-merge/merge-tree-output.ts create mode 100644 src/application/sdk-merge/sdk-merge.ts create mode 100644 src/infrastructure/git-service.ts create mode 100644 src/infrastructure/merge-service.ts create mode 100644 src/types/sdk/git-version.ts create mode 100644 src/types/sdk/tree-id.ts create mode 100644 test/application/sdk-merge/markers.test.ts create mode 100644 test/application/sdk-merge/merge-tree-output.test.ts create mode 100644 test/application/sdk-merge/sdk-merge.test.ts create mode 100644 test/infrastructure/git-service.test.ts create mode 100644 test/infrastructure/merge-service.test.ts create mode 100644 test/types/sdk/git-version.test.ts diff --git a/package.json b/package.json index 5d8351f20..28f84f20c 100644 --- a/package.json +++ b/package.json @@ -83,7 +83,6 @@ "fumadocs-ui": "16.15.17", "get-port": "^7.1.0", "is-in-ci": "^2.0.0", - "isomorphic-git": "^1.44.2", "lucide-react": "1.47.0", "mustache": "^4.2.0", "neverthrow": "^8.2.0", diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 197a04733..e564da497 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -109,9 +109,6 @@ importers: is-in-ci: specifier: ^2.0.0 version: 2.0.0 - isomorphic-git: - specifier: ^1.44.2 - version: 1.44.2 lucide-react: specifier: 1.47.0 version: 1.47.0(react@19.2.8) @@ -2934,10 +2931,6 @@ packages: '@yuku-toolchain/types@0.9.5': resolution: {integrity: sha512-KiuLNNgX9uNealaWAR+G3/cMXnRk9x4TY2EkYe/KIag+UPdwiA0RRf1hr1WAxzTP8KGzCTkqUdLPvqh32sEO3w==} - abort-controller@3.0.0: - resolution: {integrity: sha512-h8lQ8tacZYnR3vNQTgibj+tODHI5/+l06Au2Pcriv/Gmet0eaj4TwWH41sO9wnHDiQsEj19q0drzdWdeAHtweg==} - engines: {node: '>=6.5'} - acorn-jsx@5.3.2: resolution: {integrity: sha512-rq9s+JNhf0IChjtDXxllJ7g41oZk5SlXtp0LHwyA5cejwn7vKmKp4pPri6YEePv2PU65sAsegbXtIinmDFDXgQ==} peerDependencies: @@ -3079,9 +3072,6 @@ packages: resolution: {integrity: sha512-hsU18Ae8CDTR6Kgu9DYf0EbCr/a5iGL0rytQDobUcdpYOKokk8LEjVphnXkDkgpi0wYVsqrXuP0bZxJaTqdgoA==} engines: {node: '>= 0.4'} - async-lock@1.4.1: - resolution: {integrity: sha512-Az2ZTpuytrtqENulXwO3GGv1Bztugx6TT37NIo7imr/Qo0gsYiGtSdBa2B6fsXhTpVZDNfu1Qn3pk531e3q+nQ==} - async-mutex@0.5.0: resolution: {integrity: sha512-1A94B18jkJ3DYq284ohPxoXbfTA5HsQ7/Mf4DEhcyLx3Bz27Rh59iScbB6EPiP+B+joue6YCxcMXSbFC1tZKwA==} @@ -3114,9 +3104,6 @@ packages: resolution: {integrity: sha512-BLrgEcRTwX2o6gGxGOCNyMvGSp35YofuYzw9h1IMTRmKqttAZZVU67bdb9Pr2vUHA8+j3i2tJfjO6C6+4myGTA==} engines: {node: 18 || 20 || >=22} - base64-js@1.5.1: - resolution: {integrity: sha512-AKpaYlHn8t4SVbOHCy+b5+KKgvR4vrsD8vbvrbiQJps7fKDTkjkDry6ji0rUJjC0kzbNePLwzxq8iypo41qeWA==} - before-after-hook@4.0.0: resolution: {integrity: sha512-q6tR3RPqIB1pMiTRMFcZwuG5T8vwp+vUvEG0vuI6B+Rikh5BfPp2fQ82c925FOs+b0lcFQ8CFrL+KbilfZFhOQ==} @@ -3152,9 +3139,6 @@ packages: resolution: {integrity: sha512-Db1SbgBS/fg/392AblrMJk97KggmvYhr4pB5ZIMTWtaivCPMWLkmb7m21cJvpvgK+J3nsU2CmmixNBZx4vFj/w==} engines: {node: '>=8.0.0'} - buffer@6.0.3: - resolution: {integrity: sha512-FTiCpNxtwiZZHEZbcbTIcZjERVICn9yq/pDFkTl95/AxzD1naBctN7YO68riM/gLSDY7sdrMby8hofADYuuqOA==} - cacheable-lookup@7.0.0: resolution: {integrity: sha512-+qJyx4xiKra8mZrcwhjMRMUhD5NR1R8esPkzIYxX96JiecFoxAXFuz/GpR3+ev4PE1WamHip78wV0vcmPQtp8w==} engines: {node: '>=14.16'} @@ -3255,9 +3239,6 @@ packages: class-variance-authority@0.7.1: resolution: {integrity: sha512-Ka+9Trutv7G8M6WT6SeiRWz792K5qEqIGEGzXKhAE6xOWAY6pPH8U+9IY3oCMv6kqTmLsv7Xh/2w2RigkePMsg==} - clean-git-ref@2.0.1: - resolution: {integrity: sha512-bLSptAy2P0s6hU4PzuIMKmMJJSE6gLXGH1cntDu7bWJUksvuM+7ReOK61mozULErYvP6a15rnYl0zFDef+pyPw==} - clean-stack@2.2.0: resolution: {integrity: sha512-4diC9HaTE+KRAMWhDhrGOECgWZxoevMc5TlkObMqNSsVU62PYzXZ/SMTjzyGAFF1YusgxGcSWTEXBhp0CPwQ1A==} engines: {node: '>=6'} @@ -3441,11 +3422,6 @@ packages: typescript: optional: true - crc-32@1.2.2: - resolution: {integrity: sha512-ROmzCKrTnOwybPcJApAA6WBWij23HVfGVNKqqrZpuyZOHqK2CwHSvpGuyt/UNNvaIjEd8X5IFGp4Mh+Ie1IHJQ==} - engines: {node: '>=0.8'} - hasBin: true - cross-spawn@7.0.6: resolution: {integrity: sha512-uV2QOWP2nWzsy2aMp8aRibhi9dlzF5Hgh5SHaB9OiTGEyDTiJJyx0uy51QXdyWbtAHNua4XJzUKca3OzKUd3vA==} engines: {node: '>= 8'} @@ -3564,9 +3540,6 @@ packages: devlop@1.1.0: resolution: {integrity: sha512-RWmIqhcFf1lRYBvNmr7qTNuyCt/7/ns2jbpp1+PalgE/rDQcBT0fioSMUpJ93irlUhC5hrg4cYqe6U+0ImW0rA==} - diff3@0.0.3: - resolution: {integrity: sha512-iSq8ngPOt0K53A6eVr4d5Kn6GNrM2nQZtC740pzIriHtn4pOQ2lyzEXQMBeVcWERN0ye7fhBsk9PbLLQOnUx/g==} - diff@8.0.4: resolution: {integrity: sha512-DPi0FmjiSU5EvQV0++GFDOJ9ASQUVFh5kD+OzOnYdi7n3Wpm9hWWGfB/O2blfHcMVTL5WkQXSnRiK9makhrcnw==} engines: {node: '>=0.3.1'} @@ -3819,14 +3792,6 @@ packages: resolution: {integrity: sha512-kVscqXk4OCp68SZ0dkgEKVi6/8ij300KBWTJq32P/dYeWTSwK41WyTxalN1eRmA5Z9UU/LX9D7FWSmV9SAYx6g==} engines: {node: '>=0.10.0'} - event-target-shim@5.0.1: - resolution: {integrity: sha512-i/2XbnSz/uxRCU6+NdVJgKWDTM427+MqYbkQzD321DuCQJUqOuJKIA0IM2+W2xtYHdKOmZ4dR6fExsd4SXL+WQ==} - engines: {node: '>=6'} - - events@3.3.0: - resolution: {integrity: sha512-mQw+2fkQbALzQ7V0MY0IqdnXNOeTtP4r0lN9z7AAawCXgqea7bDii20AYrIBrFd/Hx0M2Ocz6S111CaFkUcb0Q==} - engines: {node: '>=0.8.x'} - execa@5.1.1: resolution: {integrity: sha512-8uSpZZocAZRBAPIEINJj3Lo9HyGitllczc27Eh5YYojjMFMn8yHMDMaUHE2Jqfq05D/wucwI4JGURyXt1vchyg==} engines: {node: '>=10'} @@ -4414,9 +4379,6 @@ packages: resolution: {integrity: sha512-im9DjEDQ55s9fL4EYzOAv0yMqmMBSZp6G0VvFyTMPKWxiSBHUj9NW/qqLmXUwXrrM7AvqSlTCfvqRb0cM8yYqw==} engines: {node: '>=0.10.0'} - ieee754@1.2.1: - resolution: {integrity: sha512-dcyqhDvX1C46lXZcVqCpK+FtMRQVdIMN6/Df5js2zouUsqG7I6sFxitIC+7KYK29KdXOLHdu9zL4sFnoVQnqaA==} - ignore@5.3.2: resolution: {integrity: sha512-hsBTNUqQTDwkWtcdYI2i06Y/nUBEsNEDJKjWdigLvegy8kDuJAS8uRlpkkcQpyEXL0Z/pjDy5HBmMjRCJ2gq+g==} engines: {node: '>= 4'} @@ -4673,11 +4635,6 @@ packages: isexe@2.0.0: resolution: {integrity: sha512-RHxMLp9lnKHGHRng9QFhRCMbYAcVpn69smSGcq3f36xjgVVWThj4qqLbTLlq7Ssj8B+fIQ1EuCEGI2lKsyQeIw==} - isomorphic-git@1.44.2: - resolution: {integrity: sha512-RORfhiNxaHRkGXvTiIW1SU6ngH/jW7UnKndvIToGxrGja7s317CUcZ5chENyinnyCPz7Z4vAefA75Ty1Zsyc8w==} - engines: {node: '>=14.17'} - hasBin: true - issue-parser@7.0.2: resolution: {integrity: sha512-7atWPjhGEIX3JEtMrOYd8TKzboYlq+5sNbdl9POiLYOI14G5HZiQbZP0Xj5EZdrufQVXfJlpTV0hys0CuxwxZw==} engines: {node: ^18.17 || >=20.6.1} @@ -5296,9 +5253,6 @@ packages: minimist@1.2.8: resolution: {integrity: sha512-2yyAR8qBkN3YuheJanUpWC5U3bb5osDywNB8RzDVlDwDHbocAJveqqj1u8+SVD7jkWT4yvsHCpWqqWqAxb0zCA==} - minimisted@2.0.1: - resolution: {integrity: sha512-1oPjfuLQa2caorJUM8HV8lGgWCc0qqAO1MNv/k05G4qslmsndV/5WdNZrqCiyqiz3wohia2Ij2B7w2Dr7/IyrA==} - minipass@7.1.3: resolution: {integrity: sha512-tEBHqDnIoM/1rXME1zgka9g6Q2lcoCkxHLuc7ODJ5BxbP5d4c2Z5cGgtXAku59200Cx7diuHTOYfSBD8n6mm8A==} engines: {node: '>=16 || 14 >=14.17'} @@ -5538,9 +5492,6 @@ packages: engines: {node: '>=18.0.0'} hasBin: true - once@1.4.0: - resolution: {integrity: sha512-lNaJgI+2Q5URQBkccEKHTQOPaXdUxnZZElQTZY0MFUAuaEqe1E+Nyvgdz/aIyNi6Z9MzO5dv1H8n58/GELp3+w==} - onetime@5.1.2: resolution: {integrity: sha512-kbpaSSGJTWdAY5KPVeMOKXSrPtr8C8C7wodJbcsd51jRnmD+GZu8Y0VoU6Dm5Z4vWr0Ig/1NKuWRKf7j5aaYSg==} engines: {node: '>=6'} @@ -5645,9 +5596,6 @@ packages: package-json-from-dist@1.0.1: resolution: {integrity: sha512-UEZIS3/by4OC8vL3P2dTXRETpebLI2NiI5vIrjaD/5UtrkFX/tNbwjTSRAGC/+7CAo2pIcBaRgWmcBBHcsaCIw==} - pako@1.0.11: - resolution: {integrity: sha512-4hLB8Py4zZce5s4yd9XzopqwVv/yGNhV1Bl8NTmCq1763HeK2+EwVTv+leGeL13Dnh2wfbqowVPXCIO0z4taYw==} - param-case@3.0.4: resolution: {integrity: sha512-RXlj7zCYokReqWpOPH9oYivUzLYZ5vAPIfEmCTNViosC78F8F0H9y7T7gG2M39ymgutxF5gcFEsyZQSph9Bp3A==} @@ -5744,10 +5692,6 @@ packages: resolution: {integrity: sha512-C3FsVNH1udSEX48gGX1xfvwTWfsYWj5U+8/uK15BGzIGrKoUpghX8hWZwa/OFnakBiiVNmBvemTJR5mcy7iPcg==} engines: {node: '>=4'} - pify@4.0.1: - resolution: {integrity: sha512-uB80kBFb/tfd68bVleG9T5GGsGPjJrLAUpR5PZIrhBnIaRTQRjqdJSsIKkOP6OAIFbj7GOrcudc5pNjZ+geV2g==} - engines: {node: '>=6'} - pkg-conf@2.1.0: resolution: {integrity: sha512-C+VUP+8jis7EsQZIhDYmS5qlNtjv2yP4SNtjXK9AP1ZcTRlnSfuumaTnRfYZnYgUUYVIKqL0fRvmUGDV2fmp6g==} engines: {node: '>=4'} @@ -5792,10 +5736,6 @@ packages: resolution: {integrity: sha512-JOnOPQ/8TZgjs1JIH/m9ni7FfimjNa/PRx7y/Wb5qdItsnhO0jE4AT7fC0HjC28DUQWDr50dwSYZLdRMlqDq3Q==} engines: {node: '>=8'} - process@0.11.10: - resolution: {integrity: sha512-cdGef/drWFoydD1JsMzuFf8100nZl+GT+yacc2bEced5f9Rjk4z+WtFUTBu9PhOi9j/jfmBPu0mMEY4wIdAF8A==} - engines: {node: '>= 0.6.0'} - propagate@2.0.1: resolution: {integrity: sha512-vGrhOavPSTz4QVNuBNdcNXePNdNMaO1xj9yBeH1ScQPjk/rhg9sSlCXPhMkFuaNNW/syTvYqsnbIJxMBfRbbag==} engines: {node: '>= 8'} @@ -5883,10 +5823,6 @@ packages: readable-stream@2.3.8: resolution: {integrity: sha512-8p0AUk4XODgIewSi0l8Epjs+EVnWiK7NoDIEGU0HhE7+ZyY8D1IMY7odu5lRrFXGg71L15KG8QrPmum45RTtdA==} - readable-stream@4.8.0: - resolution: {integrity: sha512-2jhTTU3Eilp/2DMT71gbcoqC35eQHasMJH0RHkYxwlxIv3+FR6Y5xAFZIwEGLxKkjZYEFlsiMNIdwUqjK2lLHQ==} - engines: {node: ^12.22.0 || ^14.17.0 || >=16.0.0} - readdirp@4.1.2: resolution: {integrity: sha512-GDhwkLfywWL2s6vEjyhri+eXmfH6j1L7JE27WhqLeYzoh/A3DBaYGEj2H/HFZCn/kMfim73FXxEJTw06WtxQwg==} engines: {node: '>= 14.18.0'} @@ -6097,11 +6033,6 @@ packages: resolution: {integrity: sha512-RJRdvCo6IAnPdsvP/7m6bsQqNnn1FCBX5ZNtFL98MmFF/4xAIJTIg1YbHW5DC2W5SKZanrC6i4HsJqlajw/dZw==} engines: {node: '>= 0.4'} - sha.js@2.4.12: - resolution: {integrity: sha512-8LzC5+bvI45BjpfXU8V5fdU2mfeKiQe1D1gIMn7XUlF3OTUrpdJpPPH4EMAnF0DsHHdSZqCdSss5qCmJKuiO3w==} - engines: {node: '>= 0.10'} - hasBin: true - shebang-command@2.0.0: resolution: {integrity: sha512-kHxr2zZpYtdmrN1qDjrrX/Z1rR1kG8Dx+gkpK1G4eXmvXswmcE1hTWBWYUzlraYw1/yZp6YuDY77YtvbN0dmDA==} engines: {node: '>=8'} @@ -6141,12 +6072,6 @@ packages: resolution: {integrity: sha512-iuh+gPf28RkltuJC7W5MRi6XAjTDCAPC/prJUpQoG4vIP3MJZ+GTydVnodXA7pwvTKb2cA0m9OFZW/cdWy/I/w==} engines: {node: '>=6'} - simple-concat@1.0.1: - resolution: {integrity: sha512-cSFtAPtRhljv69IK0hTVZQ+OfE9nePi/rtJmw5UjHeVyVroEqJXP1sFztKUy1qU+xvz3u/sfYJLa947b7nAN2Q==} - - simple-get@4.0.1: - resolution: {integrity: sha512-brv7p5WgH0jmQJr1ZDDfKDOSeWWg+OVypG99A/5vYGPqJ6pxiaHLy8nxtFjBA7oMa01ebA9gfh1uMCFqOuXxvA==} - simple-git@3.36.0: resolution: {integrity: sha512-cGQjLjK8bxJw4QuYT7gxHw3/IouVESbhahSsHrX97MzCL1gu2u7oy38W6L2ZIGECEfIBG4BabsWDPjBxJENv9Q==} @@ -6264,9 +6189,6 @@ packages: string_decoder@1.1.1: resolution: {integrity: sha512-n/ShnvDi6FHbbVfviro+WojiFzv+s8MPMHBczVePfUpDJLwoLT0ht1l4YwBCbi8pJAveEEdnkHyPyTP/mzRfwg==} - string_decoder@1.3.0: - resolution: {integrity: sha512-hkRX8U1WjJFd8LsDJ2yQ/wWWxaopEsABU1XfkM8A+j0+85JAGppt16cr1Whg6KIbb4okU6Mql6BOj+uup/wKeA==} - stringify-entities@4.0.4: resolution: {integrity: sha512-IwfBptatlO+QCJUo19AqvrPNqlVMpW9YEL2LIVY+Rpv2qsjCGxaDLNRgeGsQWJhfItebuJhsGSLjaBbNSQ+ieg==} @@ -6407,10 +6329,6 @@ packages: resolution: {integrity: sha512-voyz6MApa1rQGUxT3E+BK7/ROe8itEx7vD8/HEvt4xwXucvQ5G5oeEiHkmHZJuBO21RpOf+YYm9MOivj709jow==} engines: {node: '>=14.14'} - to-buffer@1.2.2: - resolution: {integrity: sha512-db0E3UJjcFhpDhAF4tLo03oli3pwl3dbnzXOUIlRKrp+ldk/VUxzpWYZENsw2SZiuBjHAk7DfB0VU7NKdpb6sw==} - engines: {node: '>= 0.4'} - to-regex-range@5.0.1: resolution: {integrity: sha512-65P7iz6X5yEr1cwcgvQxbbIw7Uk3gOy5dIdtZ4rDveLqhrdJP+Li/Hx6tyK0NEb+2GCyneCMJiGqrADCSNk8sQ==} engines: {node: '>=8.0'} @@ -6814,9 +6732,6 @@ packages: resolution: {integrity: sha512-42AtmgqjV+X1VpdOfyTGOYRi0/zsoLqtXQckTmqTeybT+BDIbM/Guxo7x3pE2vtpr1ok6xRqM9OpBe+Jyoqyww==} engines: {node: '>=18'} - wrappy@1.0.2: - resolution: {integrity: sha512-l4Sp/DRseor9wL6EvV2+TuQn63dMkPjZ/sp9XkghTEbV9KlPS1xUsZ3u7/IQO4wxtcFB4bgpQPRcR3QCvezPcQ==} - write-file-atomic@3.0.3: resolution: {integrity: sha512-AvHcyZ5JnSfq3ioSyjrBkH9yW4m7Ayk8/9My/DD9onKeu/94fwrMocemO2QAJFAlnnDN+ZDS+ZjAR5ua1/PV/Q==} @@ -9967,10 +9882,6 @@ snapshots: '@yuku-toolchain/types@0.9.5': {} - abort-controller@3.0.0: - dependencies: - event-target-shim: 5.0.1 - acorn-jsx@5.3.2(acorn@8.16.0): dependencies: acorn: 8.16.0 @@ -10115,8 +10026,6 @@ snapshots: async-function@1.0.0: {} - async-lock@1.4.1: {} - async-mutex@0.5.0: dependencies: tslib: 2.8.1 @@ -10156,8 +10065,6 @@ snapshots: balanced-match@4.0.4: {} - base64-js@1.5.1: {} - before-after-hook@4.0.0: {} bottleneck@2.19.5: {} @@ -10192,11 +10099,6 @@ snapshots: buffer-crc32@1.0.0: {} - buffer@6.0.3: - dependencies: - base64-js: 1.5.1 - ieee754: 1.2.1 - cacheable-lookup@7.0.0: {} cacheable-request@10.2.14: @@ -10320,8 +10222,6 @@ snapshots: dependencies: clsx: 2.1.1 - clean-git-ref@2.0.1: {} - clean-stack@2.2.0: {} clean-stack@3.0.1: @@ -10499,8 +10399,6 @@ snapshots: optionalDependencies: typescript: '@typescript/typescript6@6.0.2' - crc-32@1.2.2: {} - cross-spawn@7.0.6: dependencies: path-key: 3.1.1 @@ -10601,8 +10499,6 @@ snapshots: dependencies: dequal: 2.0.3 - diff3@0.0.3: {} - diff@8.0.4: {} dir-glob@3.0.1: @@ -10995,10 +10891,6 @@ snapshots: esutils@2.0.3: {} - event-target-shim@5.0.1: {} - - events@3.3.0: {} - execa@5.1.1: dependencies: cross-spawn: 7.0.6 @@ -11682,8 +11574,6 @@ snapshots: dependencies: safer-buffer: 2.1.2 - ieee754@1.2.1: {} - ignore@5.3.2: {} ignore@7.0.5: {} @@ -11893,20 +11783,6 @@ snapshots: isexe@2.0.0: {} - isomorphic-git@1.44.2: - dependencies: - async-lock: 1.4.1 - clean-git-ref: 2.0.1 - crc-32: 1.2.2 - diff3: 0.0.3 - ignore: 5.3.2 - minimisted: 2.0.1 - pako: 1.0.11 - pify: 4.0.1 - readable-stream: 4.8.0 - sha.js: 2.4.12 - simple-get: 4.0.1 - issue-parser@7.0.2: dependencies: lodash.capitalize: 4.2.1 @@ -12750,10 +12626,6 @@ snapshots: minimist@1.2.8: {} - minimisted@2.0.1: - dependencies: - minimist: 1.2.8 - minipass@7.1.3: {} mocha@11.7.5: @@ -12996,10 +12868,6 @@ snapshots: - aws-crt - supports-color - once@1.4.0: - dependencies: - wrappy: 1.0.2 - onetime@5.1.2: dependencies: mimic-fn: 2.1.0 @@ -13102,8 +12970,6 @@ snapshots: package-json-from-dist@1.0.1: {} - pako@1.0.11: {} - param-case@3.0.4: dependencies: dot-case: 3.0.4 @@ -13196,8 +13062,6 @@ snapshots: pify@3.0.0: {} - pify@4.0.1: {} - pkg-conf@2.1.0: dependencies: find-up: 2.1.0 @@ -13235,8 +13099,6 @@ snapshots: dependencies: fromentries: 1.3.2 - process@0.11.10: {} - propagate@2.0.1: {} property-information@7.2.0: {} @@ -13332,14 +13194,6 @@ snapshots: string_decoder: 1.1.1 util-deprecate: 1.0.2 - readable-stream@4.8.0: - dependencies: - abort-controller: 3.0.0 - buffer: 6.0.3 - events: 3.3.0 - process: 0.11.10 - string_decoder: 1.3.0 - readdirp@4.1.2: {} readdirp@5.1.1: {} @@ -13656,12 +13510,6 @@ snapshots: es-errors: 1.3.0 es-object-atoms: 1.1.1 - sha.js@2.4.12: - dependencies: - inherits: 2.0.4 - safe-buffer: 5.2.1 - to-buffer: 1.2.2 - shebang-command@2.0.0: dependencies: shebang-regex: 3.0.0 @@ -13717,14 +13565,6 @@ snapshots: figures: 2.0.0 pkg-conf: 2.1.0 - simple-concat@1.0.1: {} - - simple-get@4.0.1: - dependencies: - decompress-response: 6.0.0 - once: 1.4.0 - simple-concat: 1.0.1 - simple-git@3.36.0: dependencies: '@kwsites/file-exists': 1.1.1 @@ -13874,10 +13714,6 @@ snapshots: dependencies: safe-buffer: 5.1.2 - string_decoder@1.3.0: - dependencies: - safe-buffer: 5.2.1 - stringify-entities@4.0.4: dependencies: character-entities-html4: 2.1.0 @@ -14005,12 +13841,6 @@ snapshots: tmp@0.2.5: {} - to-buffer@1.2.2: - dependencies: - isarray: 2.0.5 - safe-buffer: 5.2.1 - typed-array-buffer: 1.0.3 - to-regex-range@5.0.1: dependencies: is-number: 7.0.0 @@ -14390,8 +14220,6 @@ snapshots: string-width: 7.2.0 strip-ansi: 7.2.0 - wrappy@1.0.2: {} - write-file-atomic@3.0.3: dependencies: imurmurhash: 0.1.4 diff --git a/src/application/sdk-merge/markers.ts b/src/application/sdk-merge/markers.ts new file mode 100644 index 000000000..a16185d54 --- /dev/null +++ b/src/application/sdk-merge/markers.ts @@ -0,0 +1,31 @@ +import { TextDecoder, TextEncoder } from 'node:util'; + +export const YOURS_MARKER = '<<<<<<< Your changes'; +export const GENERATED_MARKER = '>>>>>>> Generated by APIMatic'; + +const decoder = new TextDecoder('utf-8', { fatal: true }); +const encoder = new TextEncoder(); + +/** + * Git labels a conflict with the ids of the two commits it merged; the file the developer + * opens names the sides instead. Only marker lines change, so the rest of the file is byte for + * byte what git produced. + */ +export function relabelMarkers(content: Uint8Array): Uint8Array { + const text = decoder.decode(content); + const relabelled = text + .replace(/^<<<<<<< [0-9a-f]{40}$/gm, YOURS_MARKER) + .replace(/^>>>>>>> [0-9a-f]{40}$/gm, GENERATED_MARKER); + return relabelled === text ? content : encoder.encode(relabelled); +} + +/** Whether `content` still carries the markers a conflicted run writes. */ +export function hasConflictMarkers(content: Uint8Array): boolean { + let text: string; + try { + text = decoder.decode(content); + } catch { + return false; + } + return text.split(/\r?\n/).some((line) => line === YOURS_MARKER || line === GENERATED_MARKER); +} diff --git a/src/application/sdk-merge/merge-plan.ts b/src/application/sdk-merge/merge-plan.ts new file mode 100644 index 000000000..1988b32ed --- /dev/null +++ b/src/application/sdk-merge/merge-plan.ts @@ -0,0 +1,76 @@ +export type ConflictKind = + /** You and the generator changed the same lines; the file holds markers. */ + | 'bothChanged' + /** You and the generator both added the path with different content; the file holds markers. */ + | 'bothAdded' + /** You deleted the file and the generator changed it; the new version is written back. */ + | 'deletedByYou' + /** You edited the file and the generator no longer produces it; your file stays. */ + | 'noLongerGenerated' + /** You and the generator both changed a binary file; yours stays. */ + | 'binary' + /** A shape git reports that fits none of the above, such as both sides renaming one file differently; `message` is git's. */ + | 'other'; + +/** A file to write, as the blob the merged tree holds for it. */ +export type FileWrite = { path: string; blob: string }; + +export type Conflict = { path: string; kind: ConflictKind; message?: string }; + +export type Rename = { from: string; to: string; by: 'generator' | 'you' }; + +export type MergeCounts = { + /** Files the generator produced for the first time. */ + added: number; + /** Unedited files replaced by their new version, or moved to the generator's new name. */ + updated: number; + /** Edited files the generator also changed, merged without conflict. */ + merged: number; + /** Unedited files the generator stopped producing. */ + removed: number; + /** Edited files the generator left alone. */ + kept: number; + /** Files neither side changed. */ + unchanged: number; +}; + +/** + * What a run writes and removes in the SDK directory, by path below it with `/` between segments. + * A path the plan does not mention is left as it is. + */ +export class MergePlan { + public readonly writes: readonly FileWrite[]; + public readonly deletes: readonly string[]; + public readonly conflicts: readonly Conflict[]; + public readonly renames: readonly Rename[]; + public readonly counts: MergeCounts; + + constructor( + writes: readonly FileWrite[], + deletes: readonly string[], + conflicts: readonly Conflict[], + renames: readonly Rename[], + counts: MergeCounts + ) { + this.writes = writes; + this.deletes = deletes; + this.conflicts = conflicts; + this.renames = renames; + this.counts = counts; + } + + public hasConflicts(): boolean { + return this.conflicts.length > 0; + } + + public conflictedPaths(): string[] { + return this.conflicts.map((conflict) => conflict.path); + } + + /** Whether the file at `path` is written with conflict markers in it. */ + public hasMarkers(path: string): boolean { + return this.conflicts.some( + (conflict) => conflict.path === path && (conflict.kind === 'bothChanged' || conflict.kind === 'bothAdded') + ); + } +} diff --git a/src/application/sdk-merge/merge-tree-output.ts b/src/application/sdk-merge/merge-tree-output.ts new file mode 100644 index 000000000..a1cfdeec1 --- /dev/null +++ b/src/application/sdk-merge/merge-tree-output.ts @@ -0,0 +1,36 @@ +import { TextDecoder } from 'node:util'; + +/** One conflicted path's version on one side: stage 1 is the base, 2 yours, 3 the generator's. */ +export type StageEntry = { mode: string; oid: string; stage: 1 | 2 | 3; path: string }; + +/** One of git's informational messages, such as `CONFLICT (modify/delete)` for a path. */ +export type MergeMessage = { paths: string[]; type: string; message: string }; + +export type MergeTreeOutput = { tree: string; stages: StageEntry[]; messages: MergeMessage[] }; + +/** + * Reads what `git merge-tree --write-tree -z` prints: the merged tree's id, then one entry per + * stage of every conflicted path, then, after an empty entry, the messages git would have shown + * during a merge, each as a count, that many paths, a type and the text. + */ +export function parseMergeTreeOutput(output: Uint8Array): MergeTreeOutput { + const fields = new TextDecoder().decode(output).split('\0'); + const tree = fields[0].trim(); + const stages: StageEntry[] = []; + let index = 1; + for (; index < fields.length && fields[index] !== ''; index += 1) { + const [meta, path] = fields[index].split('\t'); + const [mode, oid, stage] = meta.split(' '); + stages.push({ mode, oid, stage: Number(stage) as StageEntry['stage'], path }); + } + const messages: MergeMessage[] = []; + for (index += 1; index < fields.length && fields[index] !== ''; ) { + const count = Number(fields[index]); + const paths = fields.slice(index + 1, index + 1 + count); + const type = fields[index + 1 + count]; + const message = fields[index + 2 + count]; + messages.push({ paths, type, message: message.trim() }); + index += 3 + count; + } + return { tree, stages, messages }; +} diff --git a/src/application/sdk-merge/sdk-merge.ts b/src/application/sdk-merge/sdk-merge.ts new file mode 100644 index 000000000..73cc79b18 --- /dev/null +++ b/src/application/sdk-merge/sdk-merge.ts @@ -0,0 +1,137 @@ +import { Conflict, ConflictKind, FileWrite, MergeCounts, MergePlan, Rename } from './merge-plan.js'; +import { MergeTreeOutput } from './merge-tree-output.js'; + +/** How a path changed between two trees, as `diff-tree` reports it. */ +export type Change = { status: 'A' | 'M' | 'D' | 'T' | 'R' | 'C'; path: string; from?: string; blob?: string }; + +export type MergeInputs = { + merge: MergeTreeOutput; + /** Base to the disk: what you changed. Renames paired. */ + yours: readonly Change[]; + /** Base to the fresh generation: what the generator changed. Renames paired. */ + generated: readonly Change[]; + /** Disk to the merged tree: what the run writes and removes. No renames. */ + applied: readonly Change[]; + /** Every path the base holds. */ + basePaths: readonly string[]; +}; + +const CONFLICT_PREFIX = 'CONFLICT ('; + +/** The stronger of two accounts of one path, when git reports it more than once. */ +const RANK: Record = { + binary: 5, + bothAdded: 4, + deletedByYou: 3, + noLongerGenerated: 3, + bothChanged: 2, + other: 1 +}; + +function kindOf(type: string, message: string, stages: ReadonlySet): ConflictKind { + if (type === 'CONFLICT (binary)') { + return 'binary'; + } + if (message.startsWith('CONFLICT (add/add)')) { + return 'bothAdded'; + } + if (type === 'CONFLICT (modify/delete)' || type === 'CONFLICT (rename/delete)') { + return stages.has(2) ? 'noLongerGenerated' : 'deletedByYou'; + } + if (type === 'CONFLICT (contents)') { + return 'bothChanged'; + } + return 'other'; +} + +function conflictsOf(merge: MergeTreeOutput): Conflict[] { + const stagesByPath = new Map>(); + for (const entry of merge.stages) { + stagesByPath.set(entry.path, (stagesByPath.get(entry.path) ?? new Set()).add(entry.stage)); + } + const byPath = new Map(); + for (const { paths, type, message } of merge.messages) { + if (!type.startsWith(CONFLICT_PREFIX)) { + continue; + } + for (const path of paths) { + const kind = kindOf(type, message, stagesByPath.get(path) ?? new Set()); + const known = byPath.get(path); + if (known === undefined || RANK[kind] > RANK[known.kind]) { + byPath.set(path, kind === 'other' ? { path, kind, message } : { path, kind }); + } + } + } + return [...byPath.values()]; +} + +const changedPaths = (changes: readonly Change[]) => + new Map( + changes.flatMap((change) => + change.from === undefined + ? [[change.path, change]] + : [ + [change.from, change], + [change.path, change] + ] + ) + ); + +/** + * What git's merge means for the SDK directory: the writes and deletions that bring the disk + * to the merged tree, the conflicts by kind, the renames each side made, and the counts. + */ +export function planMerge(inputs: MergeInputs): MergePlan { + const conflicts = conflictsOf(inputs.merge); + const conflicted = new Set(conflicts.map((conflict) => conflict.path)); + const yours = changedPaths(inputs.yours); + const generated = changedPaths(inputs.generated); + const base = new Set(inputs.basePaths); + + const writes: FileWrite[] = []; + const deletes: string[] = []; + const counts: MergeCounts = { added: 0, updated: 0, merged: 0, removed: 0, kept: 0, unchanged: 0 }; + + for (const change of inputs.applied) { + if (change.status === 'D') { + deletes.push(change.path); + if (!conflicted.has(change.path)) { + counts.removed += 1; + } + continue; + } + writes.push({ path: change.path, blob: change.blob! }); + if (conflicted.has(change.path)) { + continue; + } + if (!base.has(change.path) && generated.get(change.path)?.status === 'A') { + counts.added += 1; + } else if (yours.get(change.path)?.status === 'M' && generated.has(change.path)) { + counts.merged += 1; + } else { + counts.updated += 1; + } + } + + for (const [path, change] of yours) { + if (change.status === 'M' && !generated.has(path) && !conflicted.has(path)) { + counts.kept += 1; + } + } + for (const path of base) { + if (!yours.has(path) && !generated.has(path)) { + counts.unchanged += 1; + } + } + + const renames: Rename[] = [ + ...inputs.generated + .filter((change) => change.status === 'R') + .map((change) => ({ from: change.from!, to: change.path, by: 'generator' as const })), + ...inputs.yours + .filter((change) => change.status === 'R') + .map((change) => ({ from: change.from!, to: change.path, by: 'you' as const })) + ]; + + return new MergePlan(writes, deletes, conflicts, renames, counts); +} diff --git a/src/infrastructure/git-service.ts b/src/infrastructure/git-service.ts new file mode 100644 index 000000000..c8af882a8 --- /dev/null +++ b/src/infrastructure/git-service.ts @@ -0,0 +1,302 @@ +import { Buffer } from 'node:buffer'; +import fs from 'node:fs'; +import path from 'node:path'; +import { execa, ExecaError } from 'execa'; +import { DirectoryPath } from '../types/file/directoryPath.js'; +import { FileName } from '../types/file/fileName.js'; +import { FilePath } from '../types/file/filePath.js'; +import { CommitId } from '../types/sdk/commit-id.js'; +import { GitVersion } from '../types/sdk/git-version.js'; +import { SnapshotRef } from '../types/sdk/snapshot-ref.js'; +import { TreeId } from '../types/sdk/tree-id.js'; +import { errorMessage } from '../utils/error-utils.js'; +import { withDirPath } from './tmp-extensions.js'; + +export type TreeEntry = { mode: string; type: 'blob' | 'tree' | 'commit'; oid: string; path: string }; + +/** One line of `diff-tree --raw`: how `path` differs between two trees, and the blob it holds in the second. */ +export type DiffEntry = { + status: 'A' | 'M' | 'D' | 'T' | 'R' | 'C'; + path: string; + /** The path in the first tree, for a rename or copy. */ + from?: string; + /** The blob in the second tree; absent for a deletion. */ + blob?: string; +}; + +export type MergeTreeRun = { conflicted: boolean; output: Uint8Array }; + +export type CommitDescription = { parents: CommitId[]; authorName: string; subject: string }; + +/** What `git` reported when a command failed. */ +export class GitError extends Error {} + +const AUTHOR = { name: 'APIMatic CLI', email: 'developer@apimatic.io' } as const; +const EMPTY_TREE = '4b825dc642cb6eb9a060e54bf8d69288fbee4904'; + +/** + * The system git, driven through its plumbing. Every call carries the same `-c` settings so a + * developer's own configuration cannot change a tree, a merge or its markers, and no command + * here touches the working tree, the index or a branch of the repository it runs in. + */ +export class GitService { + public async version(): Promise { + try { + const { stdout } = await execa('git', ['--version']); + return GitVersion.parse(stdout); + } catch { + return undefined; + } + } + + /** The root of the repository enclosing `directory`, or none when nothing does. */ + public async topLevel(directory: DirectoryPath): Promise { + try { + const { stdout } = await this.run(directory, ['rev-parse', '--show-toplevel']); + return new DirectoryPath(stdout.trim()); + } catch { + return undefined; + } + } + + public async init(directory: DirectoryPath): Promise { + await this.run(directory, ['init', '--quiet', '--initial-branch=main', '.']); + } + + /** + * `directory` as a tree in `root`'s object database, through a temporary index. With + * `include: 'all'` every file goes in; otherwise the directory's own `.gitignore` is honoured, + * except for the listed paths, which go in regardless. + */ + public async treeOf( + root: DirectoryPath, + directory: DirectoryPath, + include: 'all' | readonly string[] + ): Promise { + const gitDir = await this.gitDir(root); + return withDirPath(async (temporary) => { + const env = { GIT_INDEX_FILE: new FilePath(temporary, new FileName('index')).toString() }; + const inWorkTree = (args: string[], input?: string) => + this.run(directory, ['--git-dir', gitDir, '--work-tree', directory.toString(), ...args], env, temporary, input); + await inWorkTree(['add', '--all', ...(include === 'all' ? ['--force'] : []), '--', '.']); + const forced = + include === 'all' ? [] : include.filter((relative) => fs.existsSync(path.join(directory.toString(), relative))); + if (forced.length > 0) { + await inWorkTree(['add', '--force', '--pathspec-from-file=-', '--pathspec-file-nul'], forced.join('\0')); + } + const { stdout } = await inWorkTree(['write-tree']); + return TreeId.fromGit(stdout.trim()); + }); + } + + public async commitTree( + root: DirectoryPath, + tree: TreeId, + parents: readonly CommitId[], + message: string + ): Promise { + const env = { + GIT_AUTHOR_NAME: AUTHOR.name, + GIT_AUTHOR_EMAIL: AUTHOR.email, + GIT_COMMITTER_NAME: AUTHOR.name, + GIT_COMMITTER_EMAIL: AUTHOR.email + }; + const { stdout } = await this.run( + root, + ['commit-tree', tree.toString(), ...parents.flatMap((parent) => ['-p', parent.toString()]), '-m', message], + env + ); + return CommitId.fromGit(stdout.trim()); + } + + public async updateRef(root: DirectoryPath, ref: SnapshotRef, commit: CommitId): Promise { + await this.run(root, ['update-ref', ref.toString(), commit.toString()]); + } + + public async resolveRef(root: DirectoryPath, ref: SnapshotRef): Promise { + try { + const { stdout } = await this.run(root, ['rev-parse', '--verify', '--quiet', `${ref}^{commit}`]); + return CommitId.fromGit(stdout.trim()); + } catch { + return undefined; + } + } + + /** The full names of the refs below `prefix`. */ + public async listRefs(root: DirectoryPath, prefix: string): Promise { + const { stdout } = await this.run(root, ['for-each-ref', '--format=%(refname)', prefix]); + return stdout.split('\n').filter((line) => line !== ''); + } + + public async hasCommit(root: DirectoryPath, commit: CommitId): Promise { + try { + await this.run(root, ['cat-file', '-e', `${commit}^{commit}`]); + return true; + } catch { + return false; + } + } + + public async describe(root: DirectoryPath, commit: CommitId): Promise { + const { stdout } = await this.run(root, ['show', '--no-patch', '--format=%P%n%an%n%s', commit.toString()]); + const [parents, authorName, subject] = stdout.split('\n'); + return { + parents: parents === '' ? [] : parents.split(' ').map((sha) => CommitId.fromGit(sha)), + authorName, + subject + }; + } + + public async treeOfCommit(root: DirectoryPath, commit: CommitId): Promise { + const { stdout } = await this.run(root, ['rev-parse', `${commit}^{tree}`]); + return TreeId.fromGit(stdout.trim()); + } + + /** Every file below `tree`, with `/` between path segments. */ + public async listTree(root: DirectoryPath, tree: TreeId | CommitId): Promise { + const { stdout } = await this.run(root, ['ls-tree', '-r', '-z', tree.toString()]); + return stdout + .split('\0') + .filter((entry) => entry !== '') + .map((entry) => { + const [meta, filePath] = entry.split('\t'); + const [mode, type, oid] = meta.split(' '); + return { mode, type: type as TreeEntry['type'], oid, path: filePath }; + }); + } + + /** The contents of the given blobs, by id. */ + public async readBlobs(root: DirectoryPath, oids: readonly string[]): Promise> { + const blobs = new Map(); + if (oids.length === 0) { + return blobs; + } + const result = await this.run(root, ['cat-file', '--batch'], {}, undefined, oids.join('\n'), 'buffer'); + // execa hands a buffer-encoded stdout over as a bare Uint8Array, which cannot read its own text. + const stdout = Buffer.from(result.stdout); + let offset = 0; + while (offset < stdout.length) { + const headerEnd = stdout.indexOf(0x0a, offset); + const [oid, type, size] = stdout.subarray(offset, headerEnd).toString('utf-8').split(' '); + offset = headerEnd + 1; + if (type === 'missing') { + throw new GitError(`object ${oid} is not in the repository`); + } + const length = Number(size); + blobs.set(oid, new Uint8Array(stdout.subarray(offset, offset + length))); + offset += length + 1; + } + return blobs; + } + + /** + * `git merge-tree --write-tree` of two commits that share a parent: the merged tree and git's + * account of the conflicts, in its `-z` form, for the application layer to read. + */ + public async mergeTree(root: DirectoryPath, ours: CommitId, theirs: CommitId): Promise { + return withDirPath(async (temporary) => { + const result = await this.run( + root, + ['merge-tree', '--write-tree', '-z', ours.toString(), theirs.toString()], + {}, + temporary, + undefined, + 'buffer', + [0, 1] + ); + return { conflicted: result.exitCode === 1, output: new Uint8Array(result.stdout) }; + }); + } + + /** How `to` differs from `from`, file by file; with `renames`, a moved file is one entry rather than a deletion and an addition. */ + public async diffTrees( + root: DirectoryPath, + from: TreeId | CommitId, + to: TreeId | CommitId, + renames: boolean + ): Promise { + const { stdout } = await this.run(root, [ + 'diff-tree', + '-r', + '-z', + '--raw', + renames ? '--find-renames' : '--no-renames', + from.toString(), + to.toString() + ]); + const fields = stdout.split('\0'); + const entries: DiffEntry[] = []; + for (let index = 0; index < fields.length && fields[index] !== ''; ) { + const [, , , dstBlob, statusWithScore] = fields[index].slice(1).split(' '); + const status = statusWithScore[0] as DiffEntry['status']; + const first = fields[index + 1]; + if (status === 'R' || status === 'C') { + entries.push({ status, from: first, path: fields[index + 2], blob: dstBlob }); + index += 3; + } else { + entries.push({ status, path: first, ...(status === 'D' ? {} : { blob: dstBlob }) }); + index += 2; + } + } + return entries; + } + + public static emptyTree(): TreeId { + return TreeId.fromGit(EMPTY_TREE); + } + + private async gitDir(root: DirectoryPath): Promise { + const { stdout } = await this.run(root, ['rev-parse', '--absolute-git-dir']); + return stdout.trim(); + } + + private async run( + cwd: DirectoryPath, + args: string[], + env: Record = {}, + attributesIn?: DirectoryPath, + input?: string, + encoding: 'utf8' | 'buffer' = 'utf8', + acceptedExitCodes: readonly number[] = [0] + ) { + const settings = [ + ['core.autocrlf', 'false'], + ['core.safecrlf', 'false'], + ['core.quotePath', 'false'], + ['merge.conflictStyle', 'merge'], + ['merge.renormalize', 'true'], + ['merge.renameLimit', '10000'], + ['diff.renameLimit', '10000'], + ['gc.auto', '0'], + ...(attributesIn === undefined ? [] : [['core.attributesFile', await this.attributesFile(attributesIn)]]) + ].flatMap(([key, value]) => ['-c', `${key}=${value}`]); + try { + const result = await execa('git', [...settings, ...args], { + cwd: cwd.toString(), + env: { ...env, GIT_TERMINAL_PROMPT: '0', LC_ALL: 'C' }, + input, + encoding: encoding as 'utf8', + reject: false + }); + if (!acceptedExitCodes.includes(result.exitCode ?? -1)) { + throw new GitError(`git ${args[0]} failed: ${String(result.stderr).trim() || `exit code ${result.exitCode}`}`); + } + return result as unknown as { stdout: Buffer & string; stderr: string; exitCode: number }; + } catch (error) { + if (error instanceof GitError) { + throw error; + } + throw new GitError( + error instanceof ExecaError ? `git ${args[0]} failed: ${error.shortMessage}` : errorMessage(error) + ); + } + } + + // Trees are stored with line endings normalised, so a CRLF file and its LF twin compare equal + // and merge line by line; the attributes live in a temporary file, never in the repository. + private async attributesFile(temporary: DirectoryPath): Promise { + const file = new FilePath(temporary, new FileName('attributes')); + await fs.promises.writeFile(file.toString(), '* text=auto\n'); + return file.toString(); + } +} diff --git a/src/infrastructure/merge-service.ts b/src/infrastructure/merge-service.ts new file mode 100644 index 000000000..c5c944a2d --- /dev/null +++ b/src/infrastructure/merge-service.ts @@ -0,0 +1,72 @@ +import fs from 'node:fs'; +import path from 'node:path'; +import { err, ok, Result } from 'neverthrow'; +import { MergePlan } from '../application/sdk-merge/merge-plan.js'; +import { relabelMarkers } from '../application/sdk-merge/markers.js'; +import { parseMergeTreeOutput } from '../application/sdk-merge/merge-tree-output.js'; +import { planMerge } from '../application/sdk-merge/sdk-merge.js'; +import { CommitId } from '../types/sdk/commit-id.js'; +import { SdkRepository } from '../types/sdk/sdk-repository.js'; +import { TreeId } from '../types/sdk/tree-id.js'; +import { errorMessage } from '../utils/error-utils.js'; +import { GitService } from './git-service.js'; + +export type MergeProblem = { kind: 'gitFailed'; reason: string }; + +export type MergeOutcome = { plan: MergePlan; merged: TreeId }; + +/** + * The three-way merge of an SDK directory, done by git: the disk and the fresh generation are + * committed on the pristine base as temporary commits, `merge-tree` merges them, and the plan + * says which paths differ between the disk and the result. + */ +export class MergeService { + private readonly git = new GitService(); + + public async merge( + repository: SdkRepository, + base: CommitId, + disk: TreeId, + generated: TreeId + ): Promise> { + const root = repository.root(); + try { + const baseTree = await this.git.treeOfCommit(root, base); + const yours = await this.git.commitTree(root, disk, [base], 'apimatic: your changes'); + const theirs = await this.git.commitTree(root, generated, [base], 'apimatic: generated'); + const run = await this.git.mergeTree(root, yours, theirs); + const output = parseMergeTreeOutput(run.output); + const merged = TreeId.fromGit(output.tree); + const plan = planMerge({ + merge: output, + yours: await this.git.diffTrees(root, baseTree, disk, true), + generated: await this.git.diffTrees(root, baseTree, generated, true), + applied: await this.git.diffTrees(root, disk, merged, false), + basePaths: (await this.git.listTree(root, baseTree)).map((entry) => entry.path) + }); + return ok({ plan, merged }); + } catch (error) { + return err({ kind: 'gitFailed', reason: errorMessage(error) }); + } + } + + /** Writes and removes what the plan says, in the SDK directory, with the CLI's labels on any markers. */ + public async apply(repository: SdkRepository, plan: MergePlan): Promise> { + const sdk = repository.sdkDirectory().toString(); + try { + const blobs = await this.git.readBlobs(repository.root(), [...new Set(plan.writes.map((write) => write.blob))]); + for (const write of plan.writes) { + const content = blobs.get(write.blob)!; + const target = path.join(sdk, write.path); + await fs.promises.mkdir(path.dirname(target), { recursive: true }); + await fs.promises.writeFile(target, plan.hasMarkers(write.path) ? relabelMarkers(content) : content); + } + for (const relative of plan.deletes) { + await fs.promises.rm(path.join(sdk, relative), { force: true }); + } + return ok(undefined); + } catch (error) { + return err({ kind: 'gitFailed', reason: errorMessage(error) }); + } + } +} diff --git a/src/infrastructure/pristine-store-service.ts b/src/infrastructure/pristine-store-service.ts index c7b013989..8ab877628 100644 --- a/src/infrastructure/pristine-store-service.ts +++ b/src/infrastructure/pristine-store-service.ts @@ -1,5 +1,3 @@ -import fs from 'node:fs'; -import git, { TreeEntry } from 'isomorphic-git'; import { err, ok, Result } from 'neverthrow'; import { DirectoryPath } from '../types/file/directoryPath.js'; import { FileName } from '../types/file/fileName.js'; @@ -9,20 +7,19 @@ import { describeGeneration, GenerationRecord, SdkGeneration } from '../types/sd import { SdkRepository } from '../types/sdk/sdk-repository.js'; import { SnapshotFiles } from '../types/sdk/snapshot-files.js'; import { SnapshotRef } from '../types/sdk/snapshot-ref.js'; +import { TreeId } from '../types/sdk/tree-id.js'; import { errorMessage } from '../utils/error-utils.js'; import { FileService } from './file-service.js'; +import { GitService } from './git-service.js'; +import { canonical } from './tmp-extensions.js'; export type PristineStoreProblem = { kind: 'nestedSdkDirectory' | 'snapshotMissing' | 'gitFailed'; reason: string; }; -const AUTHOR = { name: 'APIMatic CLI', email: 'developer@apimatic.io' } as const; -const GIT_DIRECTORY = '.git'; const RECORD_DIRECTORY = '.apimatic'; const RECORD_FILE = 'generation.json'; -const BLOB_MODE = '100644'; -const TREE_MODE = '040000'; /** * The snapshots behind custom code, kept as commits under hidden refs of the repository that @@ -32,19 +29,19 @@ const TREE_MODE = '040000'; */ export class PristineStoreService { private readonly fileService = new FileService(); + private readonly git = new GitService(); + // Git answers with the real path; the directory the caller holds may be an 8.3 alias on + // Windows, so both are spelt the real way before one is asked whether it contains the other. public async findRepository(sdkDirectory: DirectoryPath): Promise { - try { - const root = await git.findRoot({ fs, filepath: sdkDirectory.toString() }); - return SdkRepository.create(new DirectoryPath(root), sdkDirectory); - } catch { - return undefined; - } + const real = new DirectoryPath(canonical(sdkDirectory.toString())); + const root = await this.git.topLevel(real); + return root === undefined ? undefined : SdkRepository.create(new DirectoryPath(canonical(root.toString())), real); } public async initRepository(sdkDirectory: DirectoryPath): Promise> { try { - await git.init({ fs, dir: sdkDirectory.toString(), defaultBranch: 'main' }); + await this.git.init(sdkDirectory); const repository = SdkRepository.create(sdkDirectory, sdkDirectory); return repository === undefined ? err(gitFailed('the repository could not be opened')) : ok(repository); } catch (error) { @@ -54,39 +51,61 @@ export class PristineStoreService { /** The newest pristine snapshot of the SDK directory, or none when nothing was recorded there. */ public async pristineTip(repository: SdkRepository): Promise { - return this.resolve(repository, repository.pristineRef()); + return this.git.resolveRef(repository.root(), repository.pristineRef()); } - /** Records `generated`, a freshly generated SDK, as the next pristine snapshot of the SDK directory. */ + /** A freshly generated SDK as a tree: every file, whatever its own `.gitignore` says. */ + public async snapshotGenerated( + repository: SdkRepository, + generated: DirectoryPath + ): Promise> { + try { + return ok(await this.git.treeOf(repository.root(), generated, 'all')); + } catch (error) { + return err(gitFailed(errorMessage(error))); + } + } + + /** + * The SDK directory as it is on disk: its `.gitignore` is honoured, except that every path + * `base` holds goes in, so a generated file an ignore rule matches is never taken for deleted. + */ + public async snapshotDisk( + repository: SdkRepository, + base: CommitId | undefined + ): Promise> { + try { + const include = + base === undefined ? [] : (await this.git.listTree(repository.root(), base)).map((entry) => entry.path); + return ok(await this.git.treeOf(repository.root(), repository.sdkDirectory(), include)); + } catch (error) { + return err(gitFailed(errorMessage(error))); + } + } + + /** Records `generated` as the next pristine snapshot of the SDK directory, chained on the last one. */ public async recordPristine( repository: SdkRepository, - generated: DirectoryPath, + generated: TreeId, generation: SdkGeneration ): Promise> { const ref = repository.pristineRef(); - const colliding = (await this.pristineRefsIn(repository)).find((existing) => existing.collidesWith(ref)); - if (colliding !== undefined) { - return err({ - kind: 'nestedSdkDirectory', - reason: `${ref} cannot be kept beside ${colliding}: one SDK directory sits inside another` - }); - } try { - const parent = await this.resolve(repository, ref); - const tree = await this.treeOf(repository, generated, []); - const commit = await this.commit( - repository, - tree, - parent, + const colliding = (await this.pristineRefsIn(repository)).find((existing) => existing.collidesWith(ref)); + if (colliding !== undefined) { + return err({ + kind: 'nestedSdkDirectory', + reason: `${ref} cannot be kept beside ${colliding}: one SDK directory sits inside another` + }); + } + const parent = await this.git.resolveRef(repository.root(), ref); + const commit = await this.git.commitTree( + repository.root(), + generated, + parent === undefined ? [] : [parent], `apimatic: pristine ${describeGeneration(generation)}` ); - await git.writeRef({ - fs, - dir: repository.root().toString(), - ref: ref.toString(), - value: commit.toString(), - force: true - }); + await this.git.updateRef(repository.root(), ref, commit); return ok(commit); } catch (error) { return err(gitFailed(errorMessage(error))); @@ -94,18 +113,17 @@ export class PristineStoreService { } /** Keeps the SDK directory as it is on disk, replacing what the last run kept, so a merge can be undone. */ - public async savePrevious(repository: SdkRepository): Promise> { + public async savePrevious( + repository: SdkRepository, + base: CommitId | undefined + ): Promise> { + const disk = await this.snapshotDisk(repository, base); + if (disk.isErr()) { + return err(disk.error); + } try { - const excluded = repository.isRootSdk() ? [GIT_DIRECTORY] : []; - const tree = await this.treeOf(repository, repository.sdkDirectory(), excluded); - const commit = await this.commit(repository, tree, undefined, 'apimatic: before regeneration'); - await git.writeRef({ - fs, - dir: repository.root().toString(), - ref: repository.previousRef().toString(), - value: commit.toString(), - force: true - }); + const commit = await this.git.commitTree(repository.root(), disk.value, [], 'apimatic: before regeneration'); + await this.git.updateRef(repository.root(), repository.previousRef(), commit); return ok(commit); } catch (error) { return err(gitFailed(errorMessage(error))); @@ -116,16 +134,15 @@ export class PristineStoreService { repository: SdkRepository, commit: CommitId ): Promise> { - const dir = repository.root().toString(); + const root = repository.root(); try { - const { commit: object } = await git.readCommit({ fs, dir, oid: commit.toString() }); - const files = new Map(); - await this.collectFiles(dir, object.tree, '', files); - return ok(files); - } catch (error) { - if (error instanceof git.Errors.NotFoundError) { + if (!(await this.git.hasCommit(root, commit))) { return err({ kind: 'snapshotMissing', reason: `commit ${commit} is not in the repository` }); } + const entries = (await this.git.listTree(root, commit)).filter((entry) => entry.type === 'blob'); + const blobs = await this.git.readBlobs(root, [...new Set(entries.map((entry) => entry.oid))]); + return ok(new Map(entries.map((entry) => [entry.path, blobs.get(entry.oid)!]))); + } catch (error) { return err(gitFailed(errorMessage(error))); } } @@ -148,75 +165,11 @@ export class PristineStoreService { return new FilePath(sdkDirectory.join(RECORD_DIRECTORY), new FileName(RECORD_FILE)); } - private async resolve(repository: SdkRepository, ref: SnapshotRef): Promise { - try { - return CommitId.fromGit(await git.resolveRef({ fs, dir: repository.root().toString(), ref: ref.toString() })); - } catch { - return undefined; - } - } - - // The root ref and the refs below it: git keeps each as a file, so a listing of the - // namespace fails when the namespace itself is a ref, and both have to be asked for. private async pristineRefsIn(repository: SdkRepository): Promise { - const dir = repository.root().toString(); const namespace = SnapshotRef.pristineNamespace(); - const root = - (await this.resolve(repository, SnapshotRef.pristine(''))) === undefined ? [] : [SnapshotRef.pristine('')]; - const below = await git.listRefs({ fs, dir, filepath: namespace }).catch((): string[] => []); - return [...root, ...below.map((sdkPath) => SnapshotRef.pristine(sdkPath))]; - } - - private async commit( - repository: SdkRepository, - tree: string, - parent: CommitId | undefined, - message: string - ): Promise { - const signature = { ...AUTHOR, timestamp: Math.floor(Date.now() / 1000), timezoneOffset: 0 }; - const oid = await git.writeCommit({ - fs, - dir: repository.root().toString(), - commit: { - message, - tree, - parent: parent === undefined ? [] : [parent.toString()], - author: signature, - committer: signature - } - }); - return CommitId.fromGit(oid); - } - - /** Writes `directory` as git objects and returns the tree's id; an empty directory is an empty tree, as git has no other way to hold one. */ - private async treeOf(repository: SdkRepository, directory: DirectoryPath, excluded: string[]): Promise { - const dir = repository.root().toString(); - const entries: TreeEntry[] = []; - for (const entry of await fs.promises.readdir(directory.toString(), { withFileTypes: true })) { - if (excluded.includes(entry.name)) { - continue; - } - if (entry.isDirectory()) { - const oid = await this.treeOf(repository, directory.join(entry.name), []); - entries.push({ mode: TREE_MODE, path: entry.name, oid, type: 'tree' }); - } else { - const blob = await fs.promises.readFile(new FilePath(directory, new FileName(entry.name)).toString()); - const oid = await git.writeBlob({ fs, dir, blob: new Uint8Array(blob) }); - entries.push({ mode: BLOB_MODE, path: entry.name, oid, type: 'blob' }); - } - } - return git.writeTree({ fs, dir, tree: entries }); - } - - private async collectFiles(dir: string, tree: string, prefix: string, files: Map): Promise { - for (const entry of (await git.readTree({ fs, dir, oid: tree })).tree) { - const path = prefix === '' ? entry.path : `${prefix}/${entry.path}`; - if (entry.type === 'tree') { - await this.collectFiles(dir, entry.oid, path, files); - } else if (entry.type === 'blob') { - files.set(path, (await git.readBlob({ fs, dir, oid: entry.oid })).blob); - } - } + return (await this.git.listRefs(repository.root(), namespace)).map((name) => + SnapshotRef.pristine(name === namespace ? '' : name.slice(namespace.length + 1)) + ); } } diff --git a/src/types/sdk/git-version.ts b/src/types/sdk/git-version.ts new file mode 100644 index 000000000..e9901c90b --- /dev/null +++ b/src/types/sdk/git-version.ts @@ -0,0 +1,33 @@ +const REQUIRED = { major: 2, minor: 38 } as const; + +/** The version of the git on PATH, as `git --version` reports it. */ +export class GitVersion { + private readonly major: number; + private readonly minor: number; + private readonly patch: number; + + private constructor(major: number, minor: number, patch: number) { + this.major = major; + this.minor = minor; + this.patch = patch; + } + + /** From the output of `git --version`, such as `git version 2.47.1.windows.1`. */ + public static parse(output: string): GitVersion | undefined { + const match = /git version (\d+)\.(\d+)(?:\.(\d+))?/.exec(output); + return match === null ? undefined : new GitVersion(Number(match[1]), Number(match[2]), Number(match[3] ?? 0)); + } + + /** 2.38 brought `merge-tree --write-tree`, which the merge is built on. */ + public isSupported(): boolean { + return this.major > REQUIRED.major || (this.major === REQUIRED.major && this.minor >= REQUIRED.minor); + } + + public static required(): string { + return `${REQUIRED.major}.${REQUIRED.minor}`; + } + + public toString(): string { + return `${this.major}.${this.minor}.${this.patch}`; + } +} diff --git a/src/types/sdk/tree-id.ts b/src/types/sdk/tree-id.ts new file mode 100644 index 000000000..769cf0209 --- /dev/null +++ b/src/types/sdk/tree-id.ts @@ -0,0 +1,25 @@ +/** The id of a git tree: a directory's contents, which a commit points at. */ +export class TreeId { + private readonly sha: string; + + private constructor(sha: string) { + this.sha = sha; + } + + public static create(sha: string): TreeId | undefined { + return /^[0-9a-f]{40}$/.test(sha) ? new TreeId(sha) : undefined; + } + + /** From git itself, whose ids need no check. */ + public static fromGit(sha: string): TreeId { + return new TreeId(sha); + } + + public isEqual(other: TreeId): boolean { + return this.sha === other.sha; + } + + public toString(): string { + return this.sha; + } +} diff --git a/test/application/sdk-merge/markers.test.ts b/test/application/sdk-merge/markers.test.ts new file mode 100644 index 000000000..8850c670b --- /dev/null +++ b/test/application/sdk-merge/markers.test.ts @@ -0,0 +1,36 @@ +import { TextDecoder, TextEncoder } from 'node:util'; +import { expect } from 'chai'; +import { hasConflictMarkers, relabelMarkers } from '../../../src/application/sdk-merge/markers.js'; + +const bytes = (text: string) => new TextEncoder().encode(text); +const text = (content: Uint8Array) => new TextDecoder().decode(content); + +const OURS = 'a'.repeat(40); +const THEIRS = 'b'.repeat(40); + +describe('relabelMarkers', () => { + it('names the sides where git named its commits, and changes nothing else', () => { + const merged = `a\n<<<<<<< ${OURS}\nB\n=======\nX\n>>>>>>> ${THEIRS}\nc\n`; + + expect(text(relabelMarkers(bytes(merged)))).to.equal( + 'a\n<<<<<<< Your changes\nB\n=======\nX\n>>>>>>> Generated by APIMatic\nc\n' + ); + }); + + it('returns the same bytes when there is nothing to relabel', () => { + const content = bytes('<<<<<<< not a sha\nplain\n'); + + expect(relabelMarkers(content)).to.equal(content); + }); +}); + +describe('hasConflictMarkers', () => { + it('finds the markers a conflicted run writes, whatever the line ending, and nothing else', () => { + expect(hasConflictMarkers(bytes('a\n<<<<<<< Your changes\nb\n=======\nc\n>>>>>>> Generated by APIMatic\n'))).to.be + .true; + expect(hasConflictMarkers(bytes('a\r\n>>>>>>> Generated by APIMatic\r\n'))).to.be.true; + expect(hasConflictMarkers(bytes(`<<<<<<< ${OURS}\n`))).to.be.false; + expect(hasConflictMarkers(bytes('=======\n'))).to.be.false; + expect(hasConflictMarkers(new Uint8Array([0xff, 0xfe, 0x3c]))).to.be.false; + }); +}); diff --git a/test/application/sdk-merge/merge-tree-output.test.ts b/test/application/sdk-merge/merge-tree-output.test.ts new file mode 100644 index 000000000..cab9fa1da --- /dev/null +++ b/test/application/sdk-merge/merge-tree-output.test.ts @@ -0,0 +1,63 @@ +import { TextEncoder } from 'node:util'; +import { expect } from 'chai'; +import { parseMergeTreeOutput } from '../../../src/application/sdk-merge/merge-tree-output.js'; + +const TREE = '26ea736f3f455ff2dc2d7465e0c628536e5d5d52'; +const BASE = 'de980441c3ab03a8c07dda1ad27b8a11f39deb1e'; +const OURS = '7be73ce3c1b1cdaea86e8168dfee8575175953bf'; +const THEIRS = '0c02cccd4ca03aab5fcf3996660d5c0f5020a107'; + +// As git 2.47 printed it for a content conflict in same.txt and a file deleted on one side. +const output = (...fields: string[]) => new TextEncoder().encode(fields.join('\0')); + +describe('parseMergeTreeOutput', () => { + it('reads the tree, the stages of each conflicted path and the messages', () => { + const parsed = parseMergeTreeOutput( + output( + TREE, + `100644 ${BASE} 1\tsame.txt`, + `100644 ${OURS} 2\tsame.txt`, + `100644 ${THEIRS} 3\tsame.txt`, + `100644 ${BASE} 1\tgone.txt`, + `100644 ${THEIRS} 3\tgone.txt`, + '', + '1', + 'same.txt', + 'Auto-merging', + 'Auto-merging same.txt\n', + '1', + 'same.txt', + 'CONFLICT (contents)', + 'CONFLICT (content): Merge conflict in same.txt\n', + '1', + 'gone.txt', + 'CONFLICT (modify/delete)', + 'CONFLICT (modify/delete): gone.txt deleted in x and modified in y. Version y of gone.txt left in tree.\n', + '' + ) + ); + + expect(parsed.tree).to.equal(TREE); + expect(parsed.stages).to.deep.equal([ + { mode: '100644', oid: BASE, stage: 1, path: 'same.txt' }, + { mode: '100644', oid: OURS, stage: 2, path: 'same.txt' }, + { mode: '100644', oid: THEIRS, stage: 3, path: 'same.txt' }, + { mode: '100644', oid: BASE, stage: 1, path: 'gone.txt' }, + { mode: '100644', oid: THEIRS, stage: 3, path: 'gone.txt' } + ]); + expect(parsed.messages.map((message) => [message.paths, message.type])).to.deep.equal([ + [['same.txt'], 'Auto-merging'], + [['same.txt'], 'CONFLICT (contents)'], + [['gone.txt'], 'CONFLICT (modify/delete)'] + ]); + expect(parsed.messages[2].message).to.equal( + 'CONFLICT (modify/delete): gone.txt deleted in x and modified in y. Version y of gone.txt left in tree.' + ); + }); + + it('reads a clean merge as a tree and nothing else', () => { + const parsed = parseMergeTreeOutput(output(`${TREE}\n`)); + + expect(parsed).to.deep.equal({ tree: TREE, stages: [], messages: [] }); + }); +}); diff --git a/test/application/sdk-merge/sdk-merge.test.ts b/test/application/sdk-merge/sdk-merge.test.ts new file mode 100644 index 000000000..a885c2b90 --- /dev/null +++ b/test/application/sdk-merge/sdk-merge.test.ts @@ -0,0 +1,130 @@ +import { expect } from 'chai'; +import { MergeInputs, planMerge } from '../../../src/application/sdk-merge/sdk-merge.js'; +import { MergeTreeOutput } from '../../../src/application/sdk-merge/merge-tree-output.js'; + +const TREE = 't'.repeat(40); +const blob = (name: string) => name.padEnd(40, '0'); + +const merge = (overrides: Partial = {}): MergeTreeOutput => ({ + tree: TREE, + stages: [], + messages: [], + ...overrides +}); + +const inputs = (overrides: Partial): MergeInputs => ({ + merge: merge(), + yours: [], + generated: [], + applied: [], + basePaths: [], + ...overrides +}); + +describe('planMerge', () => { + it('turns the diff from the disk to the merged tree into writes and deletions, counted by who changed what', () => { + const plan = planMerge( + inputs({ + basePaths: ['a.ts', 'b.ts', 'c.ts', 'd.ts', 'e.ts'], + yours: [ + { status: 'M', path: 'b.ts', blob: blob('by') }, + { status: 'M', path: 'c.ts', blob: blob('cy') } + ], + generated: [ + { status: 'M', path: 'a.ts', blob: blob('ag') }, + { status: 'M', path: 'b.ts', blob: blob('bg') }, + { status: 'D', path: 'd.ts' }, + { status: 'A', path: 'new.ts', blob: blob('ng') } + ], + applied: [ + { status: 'M', path: 'a.ts', blob: blob('ag') }, + { status: 'M', path: 'b.ts', blob: blob('bm') }, + { status: 'D', path: 'd.ts' }, + { status: 'A', path: 'new.ts', blob: blob('ng') } + ] + }) + ); + + expect(plan.writes).to.deep.equal([ + { path: 'a.ts', blob: blob('ag') }, + { path: 'b.ts', blob: blob('bm') }, + { path: 'new.ts', blob: blob('ng') } + ]); + expect(plan.deletes).to.deep.equal(['d.ts']); + expect(plan.counts).to.deep.equal({ added: 1, updated: 1, merged: 1, removed: 1, kept: 1, unchanged: 1 }); + expect(plan.hasConflicts()).to.be.false; + }); + + it('names each conflict by its kind, from the message and from which stages are present', () => { + const plan = planMerge( + inputs({ + merge: merge({ + stages: [ + { mode: '100644', oid: blob('1'), stage: 1, path: 'deleted-by-you.ts' }, + { mode: '100644', oid: blob('3'), stage: 3, path: 'deleted-by-you.ts' }, + { mode: '100644', oid: blob('1'), stage: 1, path: 'dropped.ts' }, + { mode: '100644', oid: blob('2'), stage: 2, path: 'dropped.ts' } + ], + messages: [ + { paths: ['same.ts'], type: 'Auto-merging', message: 'Auto-merging same.ts' }, + { + paths: ['same.ts'], + type: 'CONFLICT (contents)', + message: 'CONFLICT (content): Merge conflict in same.ts' + }, + { + paths: ['added.ts'], + type: 'CONFLICT (contents)', + message: 'CONFLICT (add/add): Merge conflict in added.ts' + }, + { paths: ['logo.png'], type: 'CONFLICT (binary)', message: 'warning: Cannot merge binary files: logo.png' }, + { + paths: ['logo.png'], + type: 'CONFLICT (contents)', + message: 'CONFLICT (content): Merge conflict in logo.png' + }, + { paths: ['deleted-by-you.ts'], type: 'CONFLICT (modify/delete)', message: 'CONFLICT (modify/delete): …' }, + { paths: ['dropped.ts'], type: 'CONFLICT (modify/delete)', message: 'CONFLICT (modify/delete): …' }, + { + paths: ['x.ts', 'y.ts'], + type: 'CONFLICT (rename/rename)', + message: 'CONFLICT (rename/rename): a.ts renamed to x.ts and y.ts' + } + ] + }), + applied: [ + { status: 'M', path: 'same.ts', blob: blob('m') }, + { status: 'A', path: 'deleted-by-you.ts', blob: blob('3') } + ] + }) + ); + + expect(plan.conflicts).to.deep.equal([ + { path: 'same.ts', kind: 'bothChanged' }, + { path: 'added.ts', kind: 'bothAdded' }, + { path: 'logo.png', kind: 'binary' }, + { path: 'deleted-by-you.ts', kind: 'deletedByYou' }, + { path: 'dropped.ts', kind: 'noLongerGenerated' }, + { path: 'x.ts', kind: 'other', message: 'CONFLICT (rename/rename): a.ts renamed to x.ts and y.ts' }, + { path: 'y.ts', kind: 'other', message: 'CONFLICT (rename/rename): a.ts renamed to x.ts and y.ts' } + ]); + expect(plan.hasMarkers('same.ts')).to.be.true; + expect(plan.hasMarkers('deleted-by-you.ts')).to.be.false; + expect(plan.counts).to.deep.equal({ added: 0, updated: 0, merged: 0, removed: 0, kept: 0, unchanged: 0 }); + }); + + it('reports the renames each side made', () => { + const plan = planMerge( + inputs({ + basePaths: ['a.ts', 'b.ts'], + yours: [{ status: 'R', from: 'a.ts', path: 'moved/a.ts', blob: blob('a') }], + generated: [{ status: 'R', from: 'b.ts', path: 'b-model.ts', blob: blob('b') }] + }) + ); + + expect(plan.renames).to.deep.equal([ + { from: 'b.ts', to: 'b-model.ts', by: 'generator' }, + { from: 'a.ts', to: 'moved/a.ts', by: 'you' } + ]); + }); +}); diff --git a/test/infrastructure/git-service.test.ts b/test/infrastructure/git-service.test.ts new file mode 100644 index 000000000..9cf86743e --- /dev/null +++ b/test/infrastructure/git-service.test.ts @@ -0,0 +1,161 @@ +import fs from 'fs'; +import os from 'os'; +import path from 'path'; +import { TextDecoder } from 'node:util'; +import { expect } from 'chai'; +import { GitService } from '../../src/infrastructure/git-service.js'; +import { DirectoryPath } from '../../src/types/file/directoryPath.js'; + +const text = (content: Uint8Array | undefined) => + content === undefined ? undefined : new TextDecoder().decode(content); + +describe('GitService', () => { + let root: string; + let git: GitService; + + const write = (directory: DirectoryPath, relative: string, contents: string) => { + const target = path.join(directory.toString(), relative); + fs.mkdirSync(path.dirname(target), { recursive: true }); + fs.writeFileSync(target, contents); + }; + + const repository = async (name = 'repo'): Promise => { + const directory = new DirectoryPath(path.join(root, name)); + fs.mkdirSync(directory.toString(), { recursive: true }); + await git.init(directory); + return directory; + }; + + const filesOf = async (repo: DirectoryPath, tree: Parameters[1]) => { + const entries = await git.listTree(repo, tree); + const blobs = await git.readBlobs( + repo, + entries.map((entry) => entry.oid) + ); + return new Map(entries.map((entry) => [entry.path, text(blobs.get(entry.oid))])); + }; + + beforeEach(() => { + root = fs.mkdtempSync(path.join(os.tmpdir(), 'git-service-')); + git = new GitService(); + }); + + afterEach(() => { + fs.rmSync(root, { recursive: true, force: true }); + }); + + it('reports the git on PATH, which this suite needs at 2.38 or newer', async () => { + const version = await git.version(); + + expect(version?.isSupported()).to.be.true; + }); + + it('finds the repository enclosing a directory, and none outside one', async () => { + const repo = await repository(); + const nested = repo.join('sdk', 'typescript'); + fs.mkdirSync(nested.toString(), { recursive: true }); + const outside = new DirectoryPath(path.join(root, 'elsewhere')); + fs.mkdirSync(outside.toString()); + + expect((await git.topLevel(nested))?.isEqual(repo)).to.be.true; + expect(await git.topLevel(outside)).to.be.undefined; + expect(fs.readFileSync(path.join(repo.toString(), '.git', 'HEAD'), 'utf-8').trim()).to.equal( + 'ref: refs/heads/main' + ); + }); + + describe('treeOf', () => { + it('honours the directory’s own .gitignore, except for the paths told to include', async () => { + const repo = await repository(); + const sdk = repo.join('sdk'); + write(sdk, '.gitignore', 'dist/\n*.log\n'); + write(sdk, 'src/index.ts', 'export {};\n'); + write(sdk, 'dist/index.js', 'built\n'); + write(sdk, 'dist/generated.js', 'generated\n'); + write(sdk, 'debug.log', 'noise\n'); + + const files = await filesOf(repo, await git.treeOf(repo, sdk, ['dist/generated.js', 'missing.txt'])); + + expect([...files.keys()]).to.have.members(['.gitignore', 'src/index.ts', 'dist/generated.js']); + }); + + it('takes every file with include set to all, and leaves a .git of its own out', async () => { + const repo = await repository(); + write(repo, '.gitignore', 'dist/\n'); + write(repo, 'dist/index.js', 'built\n'); + write(repo, 'README.md', '# sdk\n'); + + const files = await filesOf(repo, await git.treeOf(repo, repo, 'all')); + + expect([...files.keys()]).to.have.members(['.gitignore', 'dist/index.js', 'README.md']); + }); + + it('stores a CRLF file with LF, whatever the developer’s core.autocrlf says', async () => { + const repo = await repository(); + write(repo, 'crlf.txt', 'one\r\ntwo\r\n'); + write(repo, 'lf.txt', 'one\ntwo\n'); + + const files = await filesOf(repo, await git.treeOf(repo, repo, 'all')); + + expect(files.get('crlf.txt')).to.equal('one\ntwo\n'); + expect(files.get('lf.txt')).to.equal('one\ntwo\n'); + }); + }); + + it('commits a tree, moves a ref to it, and describes the commit', async () => { + const repo = await repository(); + write(repo, 'a.txt', 'a\n'); + const tree = await git.treeOf(repo, repo, 'all'); + const { SnapshotRef } = await import('../../src/types/sdk/snapshot-ref.js'); + const ref = SnapshotRef.pristine('sdk'); + + const first = await git.commitTree(repo, tree, [], 'first'); + const second = await git.commitTree(repo, tree, [first], 'second'); + await git.updateRef(repo, ref, second); + + expect((await git.resolveRef(repo, ref))?.isEqual(second)).to.be.true; + expect(await git.resolveRef(repo, SnapshotRef.pristine('other'))).to.be.undefined; + expect(await git.listRefs(repo, 'refs/apimatic/pristine')).to.deep.equal(['refs/apimatic/pristine/sdk']); + expect(await git.describe(repo, second)).to.deep.equal({ + parents: [first], + authorName: 'APIMatic CLI', + subject: 'second' + }); + expect(await git.hasCommit(repo, first)).to.be.true; + expect((await git.treeOfCommit(repo, first)).isEqual(tree)).to.be.true; + }); + + it('diffs two trees, pairing a moved file only when asked to', async () => { + const repo = await repository(); + const before = repo.join('before'); + const after = repo.join('after'); + write(before, 'a.txt', 'one\ntwo\nthree\n'); + write(before, 'b.txt', 'b\n'); + write(after, 'moved/a.txt', 'one\ntwo\nthree\n'); + write(after, 'b.txt', 'B\n'); + write(after, 'c.txt', 'c\n'); + const from = await git.treeOf(repo, before, 'all'); + const to = await git.treeOf(repo, after, 'all'); + + const paired = await git.diffTrees(repo, from, to, true); + const plain = await git.diffTrees(repo, from, to, false); + + expect(paired.map(({ status, path: changed, from: origin }) => [status, changed, origin])).to.deep.equal( + [ + ['M', 'b.txt', undefined], + ['C', 'c.txt', undefined], + ['R', 'moved/a.txt', 'a.txt'] + ] + .filter(([status]) => status !== 'C') + .concat([['A', 'c.txt', undefined]]) + .sort((x, y) => String(x[1]).localeCompare(String(y[1]))) + ); + expect(plain.map(({ status, path: changed }) => [status, changed])).to.deep.equal([ + ['D', 'a.txt'], + ['M', 'b.txt'], + ['A', 'c.txt'], + ['A', 'moved/a.txt'] + ]); + expect(plain.find((change) => change.path === 'b.txt')?.blob).to.have.lengthOf(40); + }); +}); diff --git a/test/infrastructure/merge-service.test.ts b/test/infrastructure/merge-service.test.ts new file mode 100644 index 000000000..262a6b8a0 --- /dev/null +++ b/test/infrastructure/merge-service.test.ts @@ -0,0 +1,386 @@ +import fs from 'fs'; +import os from 'os'; +import path from 'path'; +import { Buffer } from 'node:buffer'; +import { expect } from 'chai'; +import { MergePlan } from '../../src/application/sdk-merge/merge-plan.js'; +import { GitService } from '../../src/infrastructure/git-service.js'; +import { MergeService } from '../../src/infrastructure/merge-service.js'; +import { PristineStoreService } from '../../src/infrastructure/pristine-store-service.js'; +import { DirectoryPath } from '../../src/types/file/directoryPath.js'; +import { CodeGenerationVersion, Language, Stability } from '../../src/types/sdk/generate.js'; +import { SdkGeneration } from '../../src/types/sdk/generation-record.js'; + +const TYPESCRIPT: SdkGeneration = { + language: Language.TYPESCRIPT, + codegenVersion: CodeGenerationVersion.V4, + stability: Stability.STABLE +}; + +type Files = Record; + +const BINARY = Buffer.from([0x89, 0x50, 0x4e, 0x47, 0x00, 0x1a]); + +// Long enough that a helper added at the end leaves the file mostly itself, as a real model is. +const MODEL_FIELDS = [ + ' /** Unique identifier of the book. */', + ' id: string;', + ' /** Title of the book. */', + ' title: string;', + ' /** Full name of the author. */', + ' author: string;', + ' /** Year of first publication. */', + ' year?: number;', + ' /** Stock status. */', + ' status?: "available" | "sold_out";' +]; +const MODEL_V1 = ['export type Book = {', ...MODEL_FIELDS, ' /** Price in USD. */', ' price: number;', '};', ''].join( + '\n' +); +const MODEL_V2 = [ + 'export type Book = {', + ...MODEL_FIELDS, + ' /** Retail price in US dollars. */', + ' price: number;', + ' isbn?: string;', + '};', + '' +].join('\n'); +const HELPER = '\nexport function formatPrice(book: Book): string {\n return `$${book.price.toFixed(2)}`;\n}\n'; +const MODEL_V1_WITH_HELPER = MODEL_V1 + HELPER; + +/** A run over real repositories: `base` was generated last time, `disk` is the SDK directory now, `generated` is the fresh output. */ +type Run = { + plan: MergePlan; + read: (relative: string) => string | undefined; + bytes: (relative: string) => Buffer; + exists: (relative: string) => boolean; + paths: () => string[]; +}; + +describe('MergeService', () => { + let root: string; + let counter = 0; + const git = new GitService(); + const store = new PristineStoreService(); + const service = new MergeService(); + + const write = (directory: DirectoryPath, files: Files) => { + for (const [relative, contents] of Object.entries(files)) { + const target = path.join(directory.toString(), relative); + fs.mkdirSync(path.dirname(target), { recursive: true }); + fs.writeFileSync(target, contents); + } + }; + + const run = async (base: Files, disk: Files, generated: Files): Promise => { + const project = new DirectoryPath(path.join(root, `project-${counter++}`)); + const sdk = project.join('sdk', 'typescript'); + fs.mkdirSync(sdk.toString(), { recursive: true }); + await git.init(project); + const repository = (await store.findRepository(sdk))!; + const baseDirectory = new DirectoryPath(path.join(root, `base-${counter}`)); + const generatedDirectory = new DirectoryPath(path.join(root, `generated-${counter}`)); + fs.mkdirSync(baseDirectory.toString()); + fs.mkdirSync(generatedDirectory.toString()); + write(baseDirectory, base); + write(sdk, disk); + write(generatedDirectory, generated); + const baseCommit = ( + await store.recordPristine( + repository, + (await store.snapshotGenerated(repository, baseDirectory))._unsafeUnwrap(), + TYPESCRIPT + ) + )._unsafeUnwrap(); + const diskTree = (await store.snapshotDisk(repository, baseCommit))._unsafeUnwrap(); + const generatedTree = (await store.snapshotGenerated(repository, generatedDirectory))._unsafeUnwrap(); + + const { plan } = (await service.merge(repository, baseCommit, diskTree, generatedTree))._unsafeUnwrap(); + (await service.apply(repository, plan))._unsafeUnwrap(); + + const at = (relative: string) => path.join(sdk.toString(), relative); + const list = (directory: string): string[] => + fs + .readdirSync(directory, { withFileTypes: true }) + .flatMap((entry) => + entry.isDirectory() + ? list(path.join(directory, entry.name)) + : [path.relative(sdk.toString(), path.join(directory, entry.name)).split(path.sep).join('/')] + ); + return { + plan, + read: (relative) => (fs.existsSync(at(relative)) ? fs.readFileSync(at(relative), 'utf-8') : undefined), + bytes: (relative) => fs.readFileSync(at(relative)), + exists: (relative) => fs.existsSync(at(relative)), + paths: () => + list(sdk.toString()) + .filter((relative) => !relative.startsWith('.git/')) + .sort() + }; + }; + + beforeEach(() => { + root = fs.mkdtempSync(path.join(os.tmpdir(), 'merge-service-')); + }); + + afterEach(() => { + fs.rmSync(root, { recursive: true, force: true }); + }); + + describe('files the base holds', () => { + it('writes the new version of a file you never edited', async () => { + const { plan, read } = await run({ 'a.ts': 'v1\n' }, { 'a.ts': 'v1\n' }, { 'a.ts': 'v2\n' }); + + expect(read('a.ts')).to.equal('v2\n'); + expect(plan.counts.updated).to.equal(1); + expect(plan.hasConflicts()).to.be.false; + }); + + it('keeps your edit when the generator left the file alone', async () => { + const { plan, read } = await run({ 'a.ts': 'v1\n' }, { 'a.ts': 'v1 edited\n' }, { 'a.ts': 'v1\n' }); + + expect(read('a.ts')).to.equal('v1 edited\n'); + expect(plan.writes).to.be.empty; + expect(plan.counts.kept).to.equal(1); + }); + + it('does nothing when neither side changed, or when you already made the generator’s change', async () => { + const same = await run({ 'a.ts': 'v1\n' }, { 'a.ts': 'v1\n' }, { 'a.ts': 'v1\n' }); + const ahead = await run({ 'a.ts': 'v1\n' }, { 'a.ts': 'v2\n' }, { 'a.ts': 'v2\n' }); + + expect(same.plan.writes).to.be.empty; + expect(same.plan.counts.unchanged).to.equal(1); + expect(ahead.plan.writes).to.be.empty; + expect(ahead.plan.hasConflicts()).to.be.false; + }); + + it('merges your edit with the generator’s change when they touch different lines', async () => { + const { plan, read } = await run( + { 'book.ts': MODEL_V1 }, + { 'book.ts': MODEL_V1_WITH_HELPER }, + { 'book.ts': MODEL_V2 } + ); + + expect(read('book.ts')).to.equal(MODEL_V2 + HELPER); + expect(plan.counts.merged).to.equal(1); + expect(plan.hasConflicts()).to.be.false; + }); + + it('marks the lines you and the generator both changed, with the CLI’s labels, and keeps the rest merged', async () => { + const yours = MODEL_V1.replace('Price in USD.', 'Price in USD, VAT included.'); + + const { plan, read } = await run({ 'book.ts': MODEL_V1 }, { 'book.ts': yours }, { 'book.ts': MODEL_V2 }); + + expect(read('book.ts')).to.equal( + [ + 'export type Book = {', + ...MODEL_FIELDS, + '<<<<<<< Your changes', + ' /** Price in USD, VAT included. */', + '=======', + ' /** Retail price in US dollars. */', + '>>>>>>> Generated by APIMatic', + ' price: number;', + ' isbn?: string;', + '};', + '' + ].join('\n') + ); + expect(plan.conflicts).to.deep.equal([{ path: 'book.ts', kind: 'bothChanged' }]); + }); + + it('leaves a file you deleted deleted while the generator does not change it', async () => { + const { plan, exists } = await run( + { 'a.ts': 'v1\n', 'b.ts': 'b\n' }, + { 'b.ts': 'b\n' }, + { 'a.ts': 'v1\n', 'b.ts': 'b\n' } + ); + + expect(exists('a.ts')).to.be.false; + expect(plan.writes).to.be.empty; + expect(plan.hasConflicts()).to.be.false; + }); + + it('writes back a file you deleted once the generator changes it, as a conflict', async () => { + const { plan, read } = await run( + { 'a.ts': 'v1\n', 'b.ts': 'b\n' }, + { 'b.ts': 'b\n' }, + { 'a.ts': 'v2\n', 'b.ts': 'b\n' } + ); + + expect(read('a.ts')).to.equal('v2\n'); + expect(plan.conflicts).to.deep.equal([{ path: 'a.ts', kind: 'deletedByYou' }]); + }); + + it('removes a file the generator stopped producing when you never edited it', async () => { + const { plan, exists } = await run( + { 'authors.ts': 'v1\n', 'b.ts': 'b\n' }, + { 'authors.ts': 'v1\n', 'b.ts': 'b\n' }, + { 'b.ts': 'b\n' } + ); + + expect(exists('authors.ts')).to.be.false; + expect(plan.deletes).to.deep.equal(['authors.ts']); + expect(plan.counts.removed).to.equal(1); + }); + + it('keeps a file the generator stopped producing when you edited it, as a conflict', async () => { + const { plan, read } = await run( + { 'authors.ts': 'v1\n', 'b.ts': 'b\n' }, + { 'authors.ts': 'v1 + getMany\n', 'b.ts': 'b\n' }, + { 'b.ts': 'b\n' } + ); + + expect(read('authors.ts')).to.equal('v1 + getMany\n'); + expect(plan.conflicts).to.deep.equal([{ path: 'authors.ts', kind: 'noLongerGenerated' }]); + }); + }); + + describe('files the base does not hold', () => { + it('adds a file the generator produced for the first time, and never touches one that is yours alone', async () => { + const { plan, read } = await run( + { 'a.ts': 'a\n' }, + { 'a.ts': 'a\n', 'custom/format.ts': 'mine\n' }, + { 'a.ts': 'a\n', 'delete-book.ts': 'new\n' } + ); + + expect(read('delete-book.ts')).to.equal('new\n'); + expect(read('custom/format.ts')).to.equal('mine\n'); + expect(plan.counts.added).to.equal(1); + expect(plan.writes.map((write) => write.path)).to.deep.equal(['delete-book.ts']); + }); + + it('marks a path you and the generator both added with different content, and accepts one with the same', async () => { + const differ = await run( + { 'a.ts': 'a\n' }, + { 'a.ts': 'a\n', 'retry.ts': 'yours\n' }, + { 'a.ts': 'a\n', 'retry.ts': 'generated\n' } + ); + const same = await run( + { 'a.ts': 'a\n' }, + { 'a.ts': 'a\n', 'retry.ts': 'same\n' }, + { 'a.ts': 'a\n', 'retry.ts': 'same\n' } + ); + + expect(differ.read('retry.ts')).to.equal( + ['<<<<<<< Your changes', 'yours', '=======', 'generated', '>>>>>>> Generated by APIMatic', ''].join('\n') + ); + expect(differ.plan.conflicts).to.deep.equal([{ path: 'retry.ts', kind: 'bothAdded' }]); + expect(same.plan.writes).to.be.empty; + expect(same.plan.hasConflicts()).to.be.false; + }); + }); + + describe('renames', () => { + it('follows a file you moved and merges the generator’s change into it where it is now', async () => { + const { plan, read, exists } = await run( + { 'models/book.ts': MODEL_V1, 'b.ts': 'b\n' }, + { 'models/catalog/book.ts': MODEL_V1_WITH_HELPER, 'b.ts': 'b\n' }, + { 'models/book.ts': MODEL_V2, 'b.ts': 'b\n' } + ); + + expect(read('models/catalog/book.ts')).to.equal(MODEL_V2 + HELPER); + expect(exists('models/book.ts')).to.be.false; + expect(plan.renames).to.deep.equal([{ from: 'models/book.ts', to: 'models/catalog/book.ts', by: 'you' }]); + expect(plan.hasConflicts()).to.be.false; + }); + + it('carries your edit to a file the generator renamed, and removes the old name', async () => { + const { plan, read, exists } = await run( + { 'models/book.ts': MODEL_V1, 'b.ts': 'b\n' }, + { 'models/book.ts': MODEL_V1_WITH_HELPER, 'b.ts': 'b\n' }, + { 'models/book-model.ts': MODEL_V2, 'b.ts': 'b\n' } + ); + + expect(read('models/book-model.ts')).to.equal(MODEL_V2 + HELPER); + expect(exists('models/book.ts')).to.be.false; + expect(plan.renames).to.deep.equal([{ from: 'models/book.ts', to: 'models/book-model.ts', by: 'generator' }]); + expect(plan.hasConflicts()).to.be.false; + }); + }); + + describe('content rules', () => { + it('reads a line ending alone as no change, writes a merged file with LF, and leaves an untouched CRLF file as it is', async () => { + const { plan, read } = await run( + { 'a.ts': 'one\ntwo\n', 'b.ts': 'b\n' }, + { 'a.ts': 'one\r\ntwo\r\nthree\r\n', 'b.ts': 'b\r\n' }, + { 'a.ts': 'zero\none\ntwo\n', 'b.ts': 'b\n' } + ); + + expect(read('a.ts')).to.equal('zero\none\ntwo\nthree\n'); + expect(read('b.ts')).to.equal('b\r\n'); + expect(plan.counts.merged).to.equal(1); + expect(plan.hasConflicts()).to.be.false; + }); + + it('replaces an unedited binary and keeps an edited one, as a conflict when the generator changed it too', async () => { + const changed = Buffer.concat([BINARY, Buffer.from([1])]); + const edited = Buffer.concat([BINARY, Buffer.from([2])]); + + const replaced = await run({ 'logo.png': BINARY }, { 'logo.png': BINARY }, { 'logo.png': changed }); + const both = await run({ 'logo.png': BINARY }, { 'logo.png': edited }, { 'logo.png': changed }); + + expect(replaced.bytes('logo.png')).to.deep.equal(changed); + expect(replaced.plan.counts.updated).to.equal(1); + expect(both.bytes('logo.png')).to.deep.equal(edited); + expect(both.plan.conflicts).to.deep.equal([{ path: 'logo.png', kind: 'binary' }]); + }); + }); + + describe('the Bookstore lab, replayed', () => { + const v1: Files = { + 'src/models/book.ts': MODEL_V1, + 'src/sdk/authors.ts': 'export class Authors {}\n', + 'docs/models/book.md': '# Book\n\nprice\n' + }; + const v2: Files = { + 'src/models/book.ts': MODEL_V2, + 'src/sdk/authors.ts': 'export class Authors {}\n', + 'src/funcs/delete-book.ts': 'export function deleteBook() {}\n', + 'docs/models/book.md': '# Book\n\nprice\nisbn\n' + }; + + it('keeps a helper, a new file and a custom file through the v2 spec, and writes back a deleted docs page as a conflict', async () => { + const { plan, read, paths } = await run( + v1, + { + 'src/models/book.ts': MODEL_V1_WITH_HELPER, + 'src/sdk/authors.ts': 'export class Authors {}\n', + 'src/custom/format.ts': 'mine\n' + }, + v2 + ); + + expect(paths()).to.deep.equal([ + 'docs/models/book.md', + 'src/custom/format.ts', + 'src/funcs/delete-book.ts', + 'src/models/book.ts', + 'src/sdk/authors.ts' + ]); + expect(read('src/models/book.ts')).to.equal(MODEL_V2 + HELPER); + expect(plan.conflicts).to.deep.equal([{ path: 'docs/models/book.md', kind: 'deletedByYou' }]); + expect(plan.counts).to.deep.equal({ added: 1, updated: 0, merged: 1, removed: 0, kept: 0, unchanged: 1 }); + }); + + it('keeps an edited file the v3 spec drops, and removes nothing else it did not produce', async () => { + const v3: Files = { 'src/models/book.ts': MODEL_V2, 'docs/models/book.md': '# Book\n\nprice\nisbn\n' }; + + const { plan, read, exists } = await run( + v1, + { + 'src/models/book.ts': MODEL_V1, + 'src/sdk/authors.ts': 'export class Authors { getMany() {} }\n', + 'docs/models/book.md': '# Book\n\nprice\n' + }, + v3 + ); + + expect(exists('src/sdk/authors.ts')).to.be.true; + expect(read('src/models/book.ts')).to.equal(MODEL_V2); + expect(plan.conflicts).to.deep.equal([{ path: 'src/sdk/authors.ts', kind: 'noLongerGenerated' }]); + expect(plan.counts.updated).to.equal(2); + }); + }); +}); diff --git a/test/infrastructure/pristine-store-service.test.ts b/test/infrastructure/pristine-store-service.test.ts index 23d85f9fc..b8e973d80 100644 --- a/test/infrastructure/pristine-store-service.test.ts +++ b/test/infrastructure/pristine-store-service.test.ts @@ -3,13 +3,14 @@ import os from 'os'; import path from 'path'; import { TextDecoder } from 'node:util'; import { expect } from 'chai'; -import git from 'isomorphic-git'; +import { GitService } from '../../src/infrastructure/git-service.js'; import { PristineStoreService } from '../../src/infrastructure/pristine-store-service.js'; import { DirectoryPath } from '../../src/types/file/directoryPath.js'; import { CommitId } from '../../src/types/sdk/commit-id.js'; import { CodeGenerationVersion, Language, Stability } from '../../src/types/sdk/generate.js'; import { GenerationRecord, SdkGeneration } from '../../src/types/sdk/generation-record.js'; import { SdkRepository } from '../../src/types/sdk/sdk-repository.js'; +import { SnapshotRef } from '../../src/types/sdk/snapshot-ref.js'; const TYPESCRIPT: SdkGeneration = { language: Language.TYPESCRIPT, @@ -20,6 +21,7 @@ const TYPESCRIPT: SdkGeneration = { describe('PristineStoreService', () => { let root: string; let service: PristineStoreService; + const git = new GitService(); const write = (directory: DirectoryPath, relative: string, contents: string) => { const target = path.join(directory.toString(), relative); @@ -35,19 +37,18 @@ describe('PristineStoreService', () => { return directory; }; + const record = async (repository: SdkRepository, files: Record) => { + const tree = (await service.snapshotGenerated(repository, generated(files)))._unsafeUnwrap(); + return service.recordPristine(repository, tree, TYPESCRIPT); + }; + const project = async (sdk = path.join('sdk', 'typescript')): Promise => { const projectDirectory = new DirectoryPath(path.join(root, 'project')); fs.mkdirSync(projectDirectory.join(sdk).toString(), { recursive: true }); - await git.init({ fs, dir: projectDirectory.toString(), defaultBranch: 'main' }); + await git.init(projectDirectory); return (await service.findRepository(projectDirectory.join(sdk)))!; }; - const read = async (repository: SdkRepository, ref: string) => - git.resolveRef({ fs, dir: repository.root().toString(), ref }); - - const commitOf = async (repository: SdkRepository, oid: string) => - (await git.readCommit({ fs, dir: repository.root().toString(), oid })).commit; - const text = (bytes: Uint8Array | undefined) => (bytes === undefined ? undefined : new TextDecoder().decode(bytes)); beforeEach(() => { @@ -77,7 +78,7 @@ describe('PristineStoreService', () => { }); describe('initRepository', () => { - it('makes the SDK directory its own repository, on main', async () => { + it('makes the SDK directory its own repository, on main, leaving its files alone', async () => { const sdk = new DirectoryPath(path.join(root, 'sdk', 'typescript')); write(sdk, 'README.md', '# sdk'); @@ -97,67 +98,62 @@ describe('PristineStoreService', () => { const repository = await project(); const commit = ( - await service.recordPristine( - repository, - generated({ 'README.md': '# v1', 'src/index.ts': 'export {};' }), - TYPESCRIPT - ) + await record(repository, { + 'README.md': '# v1', + 'src/index.ts': 'export {};', + '.gitignore': 'dist/\n', + 'dist/x.js': 'x' + }) )._unsafeUnwrap(); - expect(await read(repository, 'refs/apimatic/pristine/sdk/typescript')).to.equal(commit.toString()); - const object = await commitOf(repository, commit.toString()); - expect(object.parent).to.be.empty; - expect(object.message.trim()).to.equal('apimatic: pristine typescript v4 stable'); - expect(object.author.name).to.equal('APIMatic CLI'); + expect((await git.resolveRef(repository.root(), repository.pristineRef()))?.isEqual(commit)).to.be.true; + expect(await git.describe(repository.root(), commit)).to.deep.equal({ + parents: [], + authorName: 'APIMatic CLI', + subject: 'apimatic: pristine typescript v4 stable' + }); const files = (await service.readSnapshot(repository, commit))._unsafeUnwrap(); - expect([...files.keys()]).to.have.members(['README.md', 'src/index.ts']); + expect([...files.keys()]).to.have.members(['README.md', 'src/index.ts', '.gitignore', 'dist/x.js']); expect(text(files.get('src/index.ts'))).to.equal('export {};'); }); it('chains the next snapshot onto the last and moves the ref', async () => { const repository = await project(); - const first = ( - await service.recordPristine(repository, generated({ 'README.md': '# v1' }), TYPESCRIPT) - )._unsafeUnwrap(); + const first = (await record(repository, { 'README.md': '# v1' }))._unsafeUnwrap(); - const second = ( - await service.recordPristine(repository, generated({ 'README.md': '# v2' }), TYPESCRIPT) - )._unsafeUnwrap(); + const second = (await record(repository, { 'README.md': '# v2' }))._unsafeUnwrap(); - expect((await commitOf(repository, second.toString())).parent).to.deep.equal([first.toString()]); + expect((await git.describe(repository.root(), second)).parents).to.deep.equal([first]); expect((await service.pristineTip(repository))?.isEqual(second)).to.be.true; expect(text((await service.readSnapshot(repository, first))._unsafeUnwrap().get('README.md'))).to.equal('# v1'); }); it('refuses an SDK directory inside one that already has snapshots, and the reverse', async () => { const outer = await project(); - (await service.recordPristine(outer, generated({ 'README.md': '# outer' }), TYPESCRIPT))._unsafeUnwrap(); + (await record(outer, { 'README.md': '# outer' }))._unsafeUnwrap(); + fs.mkdirSync(outer.sdkDirectory().join('typescript').toString()); const inner = (await service.findRepository(outer.sdkDirectory().join('typescript')))!; - const refused = await service.recordPristine(inner, generated({ 'README.md': '# inner' }), TYPESCRIPT); + expect((await record(inner, { 'README.md': '# inner' }))._unsafeUnwrapErr().kind).to.equal('nestedSdkDirectory'); - expect(refused._unsafeUnwrapErr().kind).to.equal('nestedSdkDirectory'); const other = await project(path.join('other', 'typescript', 'nested')); - (await service.recordPristine(other, generated({ 'README.md': '# nested' }), TYPESCRIPT))._unsafeUnwrap(); + (await record(other, { 'README.md': '# nested' }))._unsafeUnwrap(); const parent = (await service.findRepository( new DirectoryPath(path.join(root, 'project', 'other', 'typescript')) ))!; - expect((await service.recordPristine(parent, generated({}), TYPESCRIPT))._unsafeUnwrapErr().kind).to.equal( - 'nestedSdkDirectory' - ); + expect((await record(parent, {}))._unsafeUnwrapErr().kind).to.equal('nestedSdkDirectory'); }); it('refuses any SDK directory below a repository whose root is one', async () => { const sdk = new DirectoryPath(path.join(root, 'sdk')); fs.mkdirSync(sdk.toString(), { recursive: true }); const repository = (await service.initRepository(sdk))._unsafeUnwrap(); - (await service.recordPristine(repository, generated({ 'README.md': '# root' }), TYPESCRIPT))._unsafeUnwrap(); - expect(await read(repository, 'refs/apimatic/pristine')).to.be.a('string'); + (await record(repository, { 'README.md': '# root' }))._unsafeUnwrap(); + expect(await git.resolveRef(sdk, SnapshotRef.pristine(''))).to.not.be.undefined; + fs.mkdirSync(sdk.join('typescript').toString()); const below = (await service.findRepository(sdk.join('typescript')))!; - expect((await service.recordPristine(below, generated({}), TYPESCRIPT))._unsafeUnwrapErr().kind).to.equal( - 'nestedSdkDirectory' - ); + expect((await record(below, {}))._unsafeUnwrapErr().kind).to.equal('nestedSdkDirectory'); }); }); @@ -168,19 +164,37 @@ describe('PristineStoreService', () => { write(sdk, path.join('.apimatic', 'generation.json'), '{}'); const repository = (await service.initRepository(sdk))._unsafeUnwrap(); - const first = (await service.savePrevious(repository))._unsafeUnwrap(); + const first = (await service.savePrevious(repository, undefined))._unsafeUnwrap(); write(sdk, 'README.md', '# edited again'); - const second = (await service.savePrevious(repository))._unsafeUnwrap(); + const second = (await service.savePrevious(repository, undefined))._unsafeUnwrap(); const kept = (await service.readSnapshot(repository, second))._unsafeUnwrap(); expect([...kept.keys()]).to.have.members(['README.md', '.apimatic/generation.json']); expect(text(kept.get('README.md'))).to.equal('# edited again'); - expect(await read(repository, 'refs/apimatic/previous')).to.equal(second.toString()); - expect((await commitOf(repository, second.toString())).parent).to.be.empty; + expect((await git.resolveRef(sdk, repository.previousRef()))?.isEqual(second)).to.be.true; + expect((await git.describe(sdk, second)).parents).to.be.empty; expect(text((await service.readSnapshot(repository, first))._unsafeUnwrap().get('README.md'))).to.equal( '# edited' ); }); + + it('includes a generated file the SDK’s own .gitignore matches, and leaves other ignored files out', async () => { + const repository = await project(); + const base = ( + await record(repository, { '.gitignore': 'dist/\n', 'dist/generated.js': 'generated' }) + )._unsafeUnwrap(); + const sdk = repository.sdkDirectory(); + write(sdk, '.gitignore', 'dist/\n'); + write(sdk, 'dist/generated.js', 'generated'); + write(sdk, 'dist/built.js', 'built'); + + const previous = (await service.savePrevious(repository, base))._unsafeUnwrap(); + + expect([...(await service.readSnapshot(repository, previous))._unsafeUnwrap().keys()]).to.have.members([ + '.gitignore', + 'dist/generated.js' + ]); + }); }); describe('readSnapshot', () => { @@ -198,21 +212,19 @@ describe('PristineStoreService', () => { const repository = await project(); const sdk = repository.sdkDirectory(); expect(await service.readRecord(sdk)).to.be.undefined; - const pristine = ( - await service.recordPristine(repository, generated({ 'README.md': '# v1' }), TYPESCRIPT) - )._unsafeUnwrap(); - const record = GenerationRecord.written(TYPESCRIPT, pristine, new Date('2026-10-20T10:00:00Z'), '2.3.0', [ + const pristine = (await record(repository, { 'README.md': '# v1' }))._unsafeUnwrap(); + const written = GenerationRecord.written(TYPESCRIPT, pristine, new Date('2026-10-20T10:00:00Z'), '2.3.0', [ 'src/index.ts' ]); - const file = await service.writeRecord(sdk, record); + const file = await service.writeRecord(sdk, written); expect(file.toString()).to.equal(path.join(sdk.toString(), '.apimatic', 'generation.json')); const read = (await service.readRecord(sdk))!; expect(read.pristineCommit().isEqual(pristine)).to.be.true; expect(read.conflictedPaths()).to.deep.equal(['src/index.ts']); expect(read.isFor(TYPESCRIPT)).to.be.true; - expect(read.toJson()).to.equal(record.toJson()); + expect(read.toJson()).to.equal(written.toJson()); }); it('reads as absent when the file cannot be understood', async () => { diff --git a/test/types/sdk/git-version.test.ts b/test/types/sdk/git-version.test.ts new file mode 100644 index 000000000..1f87fea02 --- /dev/null +++ b/test/types/sdk/git-version.test.ts @@ -0,0 +1,18 @@ +import { expect } from 'chai'; +import { GitVersion } from '../../../src/types/sdk/git-version.js'; + +describe('GitVersion', () => { + it('reads what git prints, on any platform', () => { + expect(GitVersion.parse('git version 2.47.1.windows.1')!.toString()).to.equal('2.47.1'); + expect(GitVersion.parse('git version 2.39.5 (Apple Git-154)')!.toString()).to.equal('2.39.5'); + expect(GitVersion.parse('git version 2.38')!.toString()).to.equal('2.38.0'); + expect(GitVersion.parse('not git')).to.be.undefined; + }); + + it('supports the merge from 2.38 on', () => { + expect(GitVersion.parse('git version 2.37.9')!.isSupported()).to.be.false; + expect(GitVersion.parse('git version 2.38.0')!.isSupported()).to.be.true; + expect(GitVersion.parse('git version 3.0.0')!.isSupported()).to.be.true; + expect(GitVersion.required()).to.equal('2.38'); + }); +}); From 4d5b9ccf75cd0f15d78fea2ef53f260890fe2e9f Mon Sep 17 00:00:00 2001 From: Asad Ali <14asadali@gmail.com> Date: Tue, 20 Oct 2026 14:34:12 +0500 Subject: [PATCH 03/29] feat(sdk): keep your edits when regenerating an SDK `sdk generate` now merges a regeneration over the developer's changes instead of emptying the directory. Before the upload, SdkPrepareAction checks for git 2.38 or newer, inspects the SDK directory through the pristine store and decides how the run will write: as before, when git is absent (with a warning that customizations will not persist) or with --zip; written and recorded, when there is no base yet; or merged against the snapshot the generation record names. After the download, SdkWriteAction writes it that way, initialises a repository in the SDK directory when nothing encloses it, records the pristine snapshot and `.apimatic/generation.json`, and reports conflicts with markers, a list of files and exit code 1. An overwrite keeps the directory's `.git`. Co-Authored-By: Claude Fable 5.1 --- src/actions/sdk/generate.ts | 25 +- src/actions/sdk/prepare.ts | 86 +++++++ src/actions/sdk/write.ts | 134 ++++++++++ src/application/sdk-merge/markers.ts | 4 +- src/infrastructure/pristine-store-service.ts | 136 +++++++++-- src/prompts/sdk/generate.ts | 29 +-- src/prompts/sdk/prepare.ts | 69 ++++++ src/prompts/sdk/write.ts | 79 ++++++ src/types/sdk-context.ts | 10 +- src/types/sdk/generate.ts | 15 ++ src/types/sdk/generation-record.ts | 4 + src/types/sdk/sdk-destination.ts | 12 + test/actions/sdk/generate.test.ts | 66 ++++- test/actions/sdk/prepare.test.ts | 187 ++++++++++++++ test/actions/sdk/write.test.ts | 228 ++++++++++++++++++ .../pristine-store-service.test.ts | 121 +++++++++- test/types/sdk-context.test.ts | 14 ++ test/types/sdk/generate.test.ts | 13 + 18 files changed, 1160 insertions(+), 72 deletions(-) create mode 100644 src/actions/sdk/prepare.ts create mode 100644 src/actions/sdk/write.ts create mode 100644 src/prompts/sdk/prepare.ts create mode 100644 src/prompts/sdk/write.ts create mode 100644 src/types/sdk/sdk-destination.ts create mode 100644 test/actions/sdk/prepare.test.ts create mode 100644 test/actions/sdk/write.test.ts diff --git a/src/actions/sdk/generate.ts b/src/actions/sdk/generate.ts index 97ac31e31..4d00075f1 100644 --- a/src/actions/sdk/generate.ts +++ b/src/actions/sdk/generate.ts @@ -5,8 +5,11 @@ import { withDirPath } from '../../infrastructure/tmp-extensions.js'; import { SdkGeneratePrompts } from '../../prompts/sdk/generate.js'; import { CommandMetadata } from '../../types/common/command-metadata.js'; import { TempContext } from '../../types/temp-context.js'; -import { isAvailableLanguage, Language, Stability } from '../../types/sdk/generate.js'; +import { codegenVersionFor, isAvailableLanguage, Language, Stability } from '../../types/sdk/generate.js'; import { ProjectContext } from '../../types/project-context.js'; +import { SdkGeneration } from '../../types/sdk/generation-record.js'; +import { SdkPrepareAction } from './prepare.js'; +import { SdkWriteAction } from './write.js'; export class GenerateAction { private readonly prompts: SdkGeneratePrompts = new SdkGeneratePrompts(); @@ -64,9 +67,14 @@ export class GenerateAction { } const sdkContext = buildFrom.sdk(language, sdkDirectory); - if (!force && (await sdkContext.exists()) && !(await this.prompts.overwriteSdk(sdkDirectory))) { - this.prompts.destinationDirNotEmpty(); - return ActionResult.cancelled(); + const generation: SdkGeneration = { + language, + codegenVersion: codegenVersionFor(language, stability), + stability + }; + const destination = await new SdkPrepareAction().execute(sdkContext, generation, force, zipSdk); + if (!destination.isSuccess()) { + return destination.discardValue(); } return await withDirPath(async (tempDirectory) => { @@ -100,14 +108,7 @@ export class GenerateAction { return ActionResult.failed(); } - const saved = await sdkContext.save(tempSdk.value, zipSdk); - if (saved.isErr()) { - this.prompts.sdkNotSaved(saved.error); - return ActionResult.failed(); - } - - this.prompts.sdkGenerated(saved.value); - return ActionResult.success(); + return await new SdkWriteAction().execute(destination.getValue(), sdkContext, tempSdk.value, generation); }); }; } diff --git a/src/actions/sdk/prepare.ts b/src/actions/sdk/prepare.ts new file mode 100644 index 000000000..6a7f8ca8f --- /dev/null +++ b/src/actions/sdk/prepare.ts @@ -0,0 +1,86 @@ +import { GitService } from '../../infrastructure/git-service.js'; +import { PristineStoreService } from '../../infrastructure/pristine-store-service.js'; +import { SdkPreparePrompts } from '../../prompts/sdk/prepare.js'; +import { SdkContext } from '../../types/sdk-context.js'; +import { SdkGeneration } from '../../types/sdk/generation-record.js'; +import { SdkDestination } from '../../types/sdk/sdk-destination.js'; +import { ActionResult } from '../action-result.js'; + +/** + * Decides how the SDK directory will be written, before anything is uploaded: a run that will be + * refused, or that the developer does not want, stops here. + */ +export class SdkPrepareAction { + private readonly prompts = new SdkPreparePrompts(); + private readonly git = new GitService(); + private readonly store = new PristineStoreService(); + + public readonly execute = async ( + sdkContext: SdkContext, + generation: SdkGeneration, + force: boolean, + zipSdk: boolean + ): Promise> => { + const decided = await this.destination(sdkContext, generation, zipSdk); + if (decided.isFailed()) { + return decided; + } + if ( + decided.getValue().kind !== 'merge' && + !force && + (await sdkContext.exists()) && + !(await this.prompts.overwriteSdk(sdkContext.directory())) + ) { + this.prompts.destinationDirNotEmpty(); + return ActionResult.cancelled(); + } + return decided; + }; + + private async destination( + sdkContext: SdkContext, + generation: SdkGeneration, + zipSdk: boolean + ): Promise> { + if (zipSdk) { + return ActionResult.success({ kind: 'overwrite', zip: true }); + } + const version = await this.git.version(); + if (version === undefined || !version.isSupported()) { + this.prompts.gitUnavailable(version); + return ActionResult.success({ kind: 'overwrite', zip: false }); + } + + const sdk = sdkContext.directory(); + const state = await this.store.inspect(sdk, generation); + if (state.isErr()) { + if (state.error.kind === 'nestedSdkDirectory') { + this.prompts.nestedSdkDirectory(state.error.reason); + } else { + this.prompts.directoryNotInspected(state.error.reason); + } + return ActionResult.failed(); + } + switch (state.value.kind) { + case 'noRepository': + return ActionResult.success({ kind: 'record', repository: undefined }); + case 'noRecord': + return ActionResult.success({ kind: 'record', repository: state.value.repository }); + case 'otherGeneration': + this.prompts.recordNotForThisGeneration(state.value.recorded, generation, sdk); + return ActionResult.success({ kind: 'record', repository: state.value.repository }); + case 'snapshotMissing': + this.prompts.snapshotMissing(state.value.commit, state.value.repository); + return ActionResult.success({ kind: 'record', repository: state.value.repository }); + case 'unresolved': + this.prompts.conflictsUnresolved(state.value.paths); + return ActionResult.failed(); + case 'ready': + return ActionResult.success({ + kind: 'merge', + repository: state.value.repository, + base: state.value.base + }); + } + } +} diff --git a/src/actions/sdk/write.ts b/src/actions/sdk/write.ts new file mode 100644 index 000000000..adfc35c59 --- /dev/null +++ b/src/actions/sdk/write.ts @@ -0,0 +1,134 @@ +import { err, ok, Result } from 'neverthrow'; +import { envInfo } from '../../infrastructure/env-info.js'; +import { MergeService } from '../../infrastructure/merge-service.js'; +import { PristineStoreProblem, PristineStoreService } from '../../infrastructure/pristine-store-service.js'; +import { SdkWritePrompts } from '../../prompts/sdk/write.js'; +import { DirectoryPath } from '../../types/file/directoryPath.js'; +import { SdkContext } from '../../types/sdk-context.js'; +import { CommitId } from '../../types/sdk/commit-id.js'; +import { SdkGeneration } from '../../types/sdk/generation-record.js'; +import { SdkDestination } from '../../types/sdk/sdk-destination.js'; +import { SdkRepository } from '../../types/sdk/sdk-repository.js'; +import { TreeId } from '../../types/sdk/tree-id.js'; +import { ActionResult } from '../action-result.js'; + +const MERGE_NEEDS_RESOLVING = 'Resolve the conflicts and rerun'; + +/** Writes a freshly generated SDK into its directory the way the destination was prepared for. */ +export class SdkWriteAction { + private readonly prompts = new SdkWritePrompts(); + private readonly store = new PristineStoreService(); + private readonly mergeService = new MergeService(); + + public readonly execute = async ( + destination: SdkDestination, + sdkContext: SdkContext, + tempSdk: DirectoryPath, + generation: SdkGeneration + ): Promise => { + switch (destination.kind) { + case 'overwrite': + return this.overwrite(sdkContext, tempSdk, destination.zip); + case 'record': + return this.overwriteAndRecord(destination.repository, sdkContext, tempSdk, generation); + case 'merge': + return this.merge(destination.repository, destination.base, sdkContext, tempSdk, generation); + } + }; + + private async overwrite(sdkContext: SdkContext, tempSdk: DirectoryPath, zipSdk: boolean): Promise { + const saved = await sdkContext.save(tempSdk, zipSdk); + if (saved.isErr()) { + this.prompts.sdkNotSaved(saved.error); + return ActionResult.failed(); + } + this.prompts.sdkGenerated(saved.value); + return ActionResult.success(); + } + + // The repository is made once the generation has succeeded, so a failed one leaves none behind, + // and before the write, which the generator's output cannot collide with. + private async overwriteAndRecord( + enclosing: SdkRepository | undefined, + sdkContext: SdkContext, + tempSdk: DirectoryPath, + generation: SdkGeneration + ): Promise { + const sdk = sdkContext.directory(); + const repository = enclosing === undefined ? await this.store.initRepository(sdk) : ok(enclosing); + if (repository.isErr()) { + return this.customCodeFailed(repository.error); + } + const saved = await sdkContext.save(tempSdk, false); + if (saved.isErr()) { + this.prompts.sdkNotSaved(saved.error); + return ActionResult.failed(); + } + if (enclosing === undefined) { + this.prompts.repositoryInitialised(sdk); + } + const generated = await this.store.snapshotGenerated(repository.value, tempSdk); + await this.record(repository.value, generated, generation, []); + this.prompts.sdkGenerated(sdk); + return ActionResult.success(); + } + + private async merge( + repository: SdkRepository, + base: CommitId, + sdkContext: SdkContext, + tempSdk: DirectoryPath, + generation: SdkGeneration + ): Promise { + const generated = await this.store.snapshotGenerated(repository, tempSdk); + if (generated.isErr()) { + return this.customCodeFailed(generated.error); + } + const previous = await this.store.savePrevious(repository, base); + if (previous.isErr()) { + return this.customCodeFailed(previous.error); + } + const disk = await this.store.snapshotDisk(repository, base); + if (disk.isErr()) { + return this.customCodeFailed(disk.error); + } + const merged = await this.mergeService.merge(repository, base, disk.value, generated.value); + if (merged.isErr()) { + return this.customCodeFailed(merged.error); + } + const { plan } = merged.value; + const applied = await this.mergeService.apply(repository, plan); + if (applied.isErr()) { + return this.customCodeFailed(applied.error); + } + await this.record(repository, generated, generation, plan.conflictedPaths()); + this.prompts.sdkMerged(sdkContext.directory(), plan); + if (plan.hasConflicts()) { + this.prompts.conflicts(plan.conflicts, repository); + return ActionResult.failed(MERGE_NEEDS_RESOLVING); + } + return ActionResult.success(); + } + + private customCodeFailed(problem: { reason: string }): ActionResult { + this.prompts.customCodeFailed(problem.reason); + return ActionResult.failed(); + } + + // The SDK is already on disk when this runs, so a failure here is a warning: the next run + // asks before replacing it, as it would any directory without a record. + private async record( + repository: SdkRepository, + generated: Result, + generation: SdkGeneration, + conflicts: readonly string[] + ): Promise { + const recorded = await generated.match( + (tree) => this.store.recordGeneration(repository, tree, generation, envInfo.getCLIVersion(), conflicts), + async (problem) => err(problem) + ); + if (recorded.isErr()) { + this.prompts.snapshotNotRecorded(recorded.error.reason); + } + } +} diff --git a/src/application/sdk-merge/markers.ts b/src/application/sdk-merge/markers.ts index a16185d54..cc7622978 100644 --- a/src/application/sdk-merge/markers.ts +++ b/src/application/sdk-merge/markers.ts @@ -20,10 +20,10 @@ export function relabelMarkers(content: Uint8Array): Uint8Array { } /** Whether `content` still carries the markers a conflicted run writes. */ -export function hasConflictMarkers(content: Uint8Array): boolean { +export function hasConflictMarkers(content: Uint8Array | string): boolean { let text: string; try { - text = decoder.decode(content); + text = typeof content === 'string' ? content : decoder.decode(content); } catch { return false; } diff --git a/src/infrastructure/pristine-store-service.ts b/src/infrastructure/pristine-store-service.ts index 8ab877628..2633fda60 100644 --- a/src/infrastructure/pristine-store-service.ts +++ b/src/infrastructure/pristine-store-service.ts @@ -1,4 +1,7 @@ +import fs from 'node:fs'; +import path from 'node:path'; import { err, ok, Result } from 'neverthrow'; +import { hasConflictMarkers } from '../application/sdk-merge/markers.js'; import { DirectoryPath } from '../types/file/directoryPath.js'; import { FileName } from '../types/file/fileName.js'; import { FilePath } from '../types/file/filePath.js'; @@ -14,13 +17,38 @@ import { GitService } from './git-service.js'; import { canonical } from './tmp-extensions.js'; export type PristineStoreProblem = { - kind: 'nestedSdkDirectory' | 'snapshotMissing' | 'gitFailed'; + kind: 'nestedSdkDirectory' | 'snapshotMissing' | 'gitFailed' | 'recordNotWritten'; reason: string; }; +/** What the store knows about an SDK directory before a run, from the repository and the record. */ +export type SdkDirectoryState = + | { kind: 'noRepository' } + | { kind: 'noRecord'; repository: SdkRepository } + | { kind: 'otherGeneration'; repository: SdkRepository; recorded: SdkGeneration } + | { kind: 'snapshotMissing'; repository: SdkRepository; commit: CommitId } + | { kind: 'unresolved'; repository: SdkRepository; paths: string[] } + | { kind: 'ready'; repository: SdkRepository; base: CommitId }; + const RECORD_DIRECTORY = '.apimatic'; const RECORD_FILE = 'generation.json'; +/** + * The directory spelt as git will spell it, and its nearest ancestor that exists, which is where + * git can be asked about it: the SDK directory of a first run is not there yet, and on Windows + * the one the caller holds may be an 8.3 alias where git answers with the long name. + */ +function realSpelling(directory: DirectoryPath): { real: DirectoryPath; existing: DirectoryPath } { + let existing = directory.toString(); + const missing: string[] = []; + while (!fs.existsSync(existing)) { + missing.unshift(path.basename(existing)); + existing = path.dirname(existing); + } + const existingReal = new DirectoryPath(canonical(existing)); + return { real: existingReal.join(...missing), existing: existingReal }; +} + /** * The snapshots behind custom code, kept as commits under hidden refs of the repository that * encloses an SDK directory: the pristine chain (one commit per generation, the base of the @@ -31,16 +59,43 @@ export class PristineStoreService { private readonly fileService = new FileService(); private readonly git = new GitService(); - // Git answers with the real path; the directory the caller holds may be an 8.3 alias on - // Windows, so both are spelt the real way before one is asked whether it contains the other. + /** Everything a run needs to know before it decides how to write; a nested SDK directory is the one refusal. */ + public async inspect( + sdkDirectory: DirectoryPath, + generation: SdkGeneration + ): Promise> { + const repository = await this.findRepository(sdkDirectory); + if (repository === undefined) { + return ok({ kind: 'noRepository' }); + } + const notNested = await this.verifyNotNested(repository); + if (notNested.isErr()) { + return err(notNested.error); + } + const record = await this.readRecord(sdkDirectory); + if (record === undefined) { + return ok({ kind: 'noRecord', repository }); + } + if (!record.isFor(generation)) { + return ok({ kind: 'otherGeneration', repository, recorded: record.generatedAs() }); + } + const base = record.pristineCommit(); + if (!(await this.git.hasCommit(repository.root(), base))) { + return ok({ kind: 'snapshotMissing', repository, commit: base }); + } + const paths = await this.unresolvedConflicts(sdkDirectory, record); + return ok(paths.length > 0 ? { kind: 'unresolved', repository, paths } : { kind: 'ready', repository, base }); + } + public async findRepository(sdkDirectory: DirectoryPath): Promise { - const real = new DirectoryPath(canonical(sdkDirectory.toString())); - const root = await this.git.topLevel(real); + const { real, existing } = realSpelling(sdkDirectory); + const root = await this.git.topLevel(existing); return root === undefined ? undefined : SdkRepository.create(new DirectoryPath(canonical(root.toString())), real); } public async initRepository(sdkDirectory: DirectoryPath): Promise> { try { + await this.fileService.createDirectoryIfNotExists(sdkDirectory); await this.git.init(sdkDirectory); const repository = SdkRepository.create(sdkDirectory, sdkDirectory); return repository === undefined ? err(gitFailed('the repository could not be opened')) : ok(repository); @@ -89,15 +144,12 @@ export class PristineStoreService { generated: TreeId, generation: SdkGeneration ): Promise> { + const notNested = await this.verifyNotNested(repository); + if (notNested.isErr()) { + return err(notNested.error); + } const ref = repository.pristineRef(); try { - const colliding = (await this.pristineRefsIn(repository)).find((existing) => existing.collidesWith(ref)); - if (colliding !== undefined) { - return err({ - kind: 'nestedSdkDirectory', - reason: `${ref} cannot be kept beside ${colliding}: one SDK directory sits inside another` - }); - } const parent = await this.git.resolveRef(repository.root(), ref); const commit = await this.git.commitTree( repository.root(), @@ -112,6 +164,22 @@ export class PristineStoreService { } } + /** The pristine snapshot first, then the record naming it, so the two never disagree. */ + public async recordGeneration( + repository: SdkRepository, + generated: TreeId, + generation: SdkGeneration, + cliVersion: string, + conflicts: readonly string[] + ): Promise> { + const pristine = await this.recordPristine(repository, generated, generation); + if (pristine.isErr()) { + return err(pristine.error); + } + const record = GenerationRecord.written(generation, pristine.value, new Date(), cliVersion, conflicts); + return (await this.writeRecord(repository.sdkDirectory(), record)).map(() => record); + } + /** Keeps the SDK directory as it is on disk, replacing what the last run kept, so a merge can be undone. */ public async savePrevious( repository: SdkRepository, @@ -155,10 +223,48 @@ export class PristineStoreService { return GenerationRecord.parse(await this.fileService.getContents(file)); } - public async writeRecord(sdkDirectory: DirectoryPath, record: GenerationRecord): Promise { + public async writeRecord( + sdkDirectory: DirectoryPath, + record: GenerationRecord + ): Promise> { const file = this.recordFile(sdkDirectory); - await this.fileService.replaceContents(file, record.toJson()); - return file; + try { + await this.fileService.replaceContents(file, record.toJson()); + return ok(file); + } catch (error) { + return err({ kind: 'recordNotWritten', reason: errorMessage(error) }); + } + } + + // The ref name is the SDK directory's path, so one SDK directory inside another cannot have its own. + private async verifyNotNested(repository: SdkRepository): Promise> { + const ref = repository.pristineRef(); + try { + const colliding = (await this.pristineRefsIn(repository)).find((existing) => existing.collidesWith(ref)); + return colliding === undefined + ? ok(undefined) + : err({ + kind: 'nestedSdkDirectory', + reason: `${ref} cannot be kept beside ${colliding}: one SDK directory sits inside another` + }); + } catch (error) { + return err(gitFailed(errorMessage(error))); + } + } + + // The files the record lists as conflicted that still hold the markers, so a run never merges over them. + private async unresolvedConflicts(sdkDirectory: DirectoryPath, record: GenerationRecord): Promise { + const unresolved: string[] = []; + for (const relative of record.conflictedPaths()) { + const file = new FilePath( + sdkDirectory.join(path.posix.dirname(relative)), + new FileName(path.posix.basename(relative)) + ); + if ((await this.fileService.fileExists(file)) && hasConflictMarkers(await this.fileService.getContents(file))) { + unresolved.push(relative); + } + } + return unresolved; } private recordFile(sdkDirectory: DirectoryPath): FilePath { diff --git a/src/prompts/sdk/generate.ts b/src/prompts/sdk/generate.ts index e3c1442e3..fff34f15c 100644 --- a/src/prompts/sdk/generate.ts +++ b/src/prompts/sdk/generate.ts @@ -1,4 +1,4 @@ -import { isCancel, confirm, log, select } from '@clack/prompts'; +import { isCancel, log, select } from '@clack/prompts'; import { DirectoryPath } from '../../types/file/directoryPath.js'; import { FileProblem } from '../../types/file/file-problem.js'; import { format as f } from '../format.js'; @@ -7,7 +7,6 @@ import { withSpinner } from '../prompt.js'; import { ServiceError } from '../../infrastructure/service-error.js'; import { AVAILABLE_LANGUAGES, Language, languageLabel, UPCOMING_LANGUAGES } from '../../types/sdk/generate.js'; import { VersionProblem } from '../../types/project-context.js'; -import { SdkSaveProblem } from '../../types/sdk-context.js'; const names = (languages: readonly Language[]) => languages.map(languageLabel).join(', '); @@ -25,19 +24,6 @@ export class SdkGeneratePrompts { ); } - public async overwriteSdk(directory: DirectoryPath): Promise { - const overwrite = await confirm({ - message: `The destination ${f.path(directory)} is not empty, do you want to overwrite?`, - initialValue: false - }); - - if (isCancel(overwrite)) { - return false; - } - - return overwrite; - } - public sameSourceAndSdkDir(directory: DirectoryPath) { const message = `The ${f.var('src')} and ${f.var('sdk')} directories must be different. ` + `Current value: ${f.path(directory)}`; @@ -54,11 +40,6 @@ export class SdkGeneratePrompts { log.error(message); } - public destinationDirNotEmpty() { - const message = `Please enter a different destination folder or remove the existing files and try again.`; - log.error(message); - } - public generateSdk(fn: Promise>) { return withSpinner('Generating SDK', 'SDK generated successfully.', 'SDK Generation failed.', fn); } @@ -75,10 +56,6 @@ export class SdkGeneratePrompts { log.error(`${ServiceError.InvalidResponse.errorMessage}\n${problem.reason}`); } - public sdkNotSaved(problem: SdkSaveProblem) { - log.error(`The SDK could not be saved to ${f.path(problem.sdkDirectory)}: ${problem.reason}`); - } - public noVersionToBuild(problem: VersionProblem, sourceDirectory: DirectoryPath) { const messages: Record = { noVersions: `The ${f.var('versioned_docs')} directory is either empty or invalid: ${f.path(sourceDirectory)}`, @@ -103,8 +80,4 @@ export class SdkGeneratePrompts { return version; } - - public sdkGenerated(sdk: DirectoryPath) { - log.info(`The generated SDK can be found at ${f.relativePath(sdk)}.`); - } } diff --git a/src/prompts/sdk/prepare.ts b/src/prompts/sdk/prepare.ts new file mode 100644 index 000000000..71cccf5e4 --- /dev/null +++ b/src/prompts/sdk/prepare.ts @@ -0,0 +1,69 @@ +import { isCancel, confirm, log } from '@clack/prompts'; +import { DirectoryPath } from '../../types/file/directoryPath.js'; +import { CommitId } from '../../types/sdk/commit-id.js'; +import { describeGeneration, SdkGeneration } from '../../types/sdk/generation-record.js'; +import { GitVersion } from '../../types/sdk/git-version.js'; +import { SdkRepository } from '../../types/sdk/sdk-repository.js'; +import { format as f } from '../format.js'; + +const plural = (count: number, noun: string) => `${count} ${noun}${count === 1 ? '' : 's'}`; + +export class SdkPreparePrompts { + public async overwriteSdk(directory: DirectoryPath): Promise { + const overwrite = await confirm({ + message: `The destination ${f.path(directory)} is not empty, do you want to overwrite?`, + initialValue: false + }); + + if (isCancel(overwrite)) { + return false; + } + + return overwrite; + } + + public destinationDirNotEmpty() { + const message = `Please enter a different destination folder or remove the existing files and try again.`; + log.error(message); + } + + public gitUnavailable(found: GitVersion | undefined) { + const reason = + found === undefined + ? 'git was not found' + : `git ${found} was found, but ${GitVersion.required()} or newer is needed`; + log.warn(`${reason}, so your customizations to the generated SDK will not persist across regenerations.`); + } + + public nestedSdkDirectory(reason: string) { + log.error(`The SDK cannot be generated here: ${reason}.`); + } + + public directoryNotInspected(reason: string) { + log.error(`The SDK directory could not be inspected for your customizations: ${reason}`); + } + + public recordNotForThisGeneration(recorded: SdkGeneration, requested: SdkGeneration, sdk: DirectoryPath) { + const message = + `${f.relativePath(sdk)} was last generated as ${describeGeneration(recorded)}; ` + + `a ${describeGeneration(requested)} SDK cannot be merged into it and will replace it.`; + log.warn(message); + } + + public snapshotMissing(commit: CommitId, repository: SdkRepository) { + const message = + `The snapshot this SDK was generated from, commit ${f.var(commit.toString())}, is not in the repository at ` + + `${f.relativePath(repository.root())}, so your customizations cannot be merged. ` + + `The SDK will be replaced and a fresh snapshot recorded.`; + log.warn(message); + } + + public conflictsUnresolved(paths: readonly string[]) { + const one = paths.length === 1; + const message = + `${plural(paths.length, 'file')} still hold${one ? 's' : ''} conflict markers from the last run. ` + + `Resolve ${one ? 'it' : 'them'} and remove the markers, then rerun:\n` + + paths.map((path) => ` ${f.var(path)}`).join('\n'); + log.error(message); + } +} diff --git a/src/prompts/sdk/write.ts b/src/prompts/sdk/write.ts new file mode 100644 index 000000000..cd9a97e5d --- /dev/null +++ b/src/prompts/sdk/write.ts @@ -0,0 +1,79 @@ +import { log } from '@clack/prompts'; +import { Conflict, ConflictKind, MergeCounts, MergePlan } from '../../application/sdk-merge/merge-plan.js'; +import { DirectoryPath } from '../../types/file/directoryPath.js'; +import { SdkSaveProblem } from '../../types/sdk-context.js'; +import { SdkRepository } from '../../types/sdk/sdk-repository.js'; +import { listedInProse } from '../../utils/string-utils.js'; +import { format as f } from '../format.js'; +import { noteWrapped } from '../prompt.js'; + +const CONFLICT_LABELS: Record = { + bothChanged: 'changed by you and by the generator; markers in the file', + bothAdded: 'added by you and by the generator; markers in the file', + deletedByYou: 'deleted by you, changed by the generator; the new version is back', + noLongerGenerated: 'no longer generated; your edited file stays', + binary: 'binary file changed by you and by the generator; yours stays', + other: '' +}; + +const COUNT_LABELS: Record = { + added: 'added', + updated: 'updated', + merged: 'merged with your changes', + removed: 'removed', + kept: 'kept as you edited', + unchanged: 'unchanged' +}; + +const plural = (count: number, noun: string) => `${count} ${noun}${count === 1 ? '' : 's'}`; + +export class SdkWritePrompts { + public sdkNotSaved(problem: SdkSaveProblem) { + log.error(`The SDK could not be saved to ${f.path(problem.sdkDirectory)}: ${problem.reason}`); + } + + public sdkGenerated(sdk: DirectoryPath) { + log.info(`The generated SDK can be found at ${f.relativePath(sdk)}.`); + } + + public repositoryInitialised(sdk: DirectoryPath) { + const message = + `Initialised a git repository in ${f.relativePath(sdk)}, ` + + `which keeps your customizations across regenerations.`; + log.info(message); + } + + public customCodeFailed(reason: string) { + log.error(`Your customizations could not be merged into the regenerated SDK: ${reason}`); + } + + public snapshotNotRecorded(reason: string) { + const message = + `The SDK was written, but its snapshot could not be recorded: ${reason}\n` + + `The next run will replace the SDK instead of merging your customizations into it.`; + log.warn(message); + } + + public sdkMerged(sdk: DirectoryPath, plan: MergePlan) { + const counts = (Object.keys(COUNT_LABELS) as (keyof MergeCounts)[]) + .filter((key) => key !== 'unchanged' && plan.counts[key] > 0) + .map((key) => `${plan.counts[key]} ${COUNT_LABELS[key]}`); + const summary = counts.length === 0 ? 'nothing changed' : listedInProse(counts); + log.info(`Regenerated the SDK at ${f.relativePath(sdk)} with your customizations: ${summary}.`); + } + + public conflicts(conflicts: readonly Conflict[], repository: SdkRepository) { + const lines = conflicts.map( + (conflict) => `${f.var(conflict.path)}\n ${conflict.message ?? CONFLICT_LABELS[conflict.kind]}` + ); + const steps = [ + 'Open each file, keep what is right, and remove the markers.', + 'Rerun the command; nothing needs committing first.', + `The directory as it was before this run is under ${f.var(repository.previousRef().toString())}.` + ]; + noteWrapped( + [...lines, '', ...steps.map((step, index) => `${index + 1}. ${step}`)].join('\n'), + `${plural(conflicts.length, 'file')} need${conflicts.length === 1 ? 's' : ''} your attention` + ); + } +} diff --git a/src/types/sdk-context.ts b/src/types/sdk-context.ts index 163d9ae83..77413e30e 100644 --- a/src/types/sdk-context.ts +++ b/src/types/sdk-context.ts @@ -9,6 +9,8 @@ import { ZipService } from '../infrastructure/zip-service.js'; export type SdkSaveProblem = FileProblem & { sdkDirectory: DirectoryPath }; +const GIT_DIRECTORY = '.git'; + export class SdkContext { private readonly fileService = new FileService(); private readonly zipService = new ZipService(); @@ -22,12 +24,18 @@ export class SdkContext { return new FilePath(this.sdkDirectory, new FileName(`${this.language}.zip`)); } + /** The language directory the SDK is written into, and read back from on the next run. */ + public directory(): DirectoryPath { + return this.sdkDirectory; + } + public async exists() { return !(await this.fileService.directoryEmpty(this.sdkDirectory)); } + /** Overwrites the directory; a `.git` in it is the developer's, or holds the snapshots, and stays. */ public async save(tempSdkDirectory: DirectoryPath, zipSdk: boolean): Promise> { - await this.fileService.cleanDirectory(this.sdkDirectory); + await this.fileService.cleanDirectoryExcluding(this.sdkDirectory, [new FileName(GIT_DIRECTORY)]); if (!zipSdk) { await this.fileService.copyDirectoryContents(tempSdkDirectory, this.sdkDirectory); return ok(this.sdkDirectory); diff --git a/src/types/sdk/generate.ts b/src/types/sdk/generate.ts index 749c5b660..0d1e717bf 100644 --- a/src/types/sdk/generate.ts +++ b/src/types/sdk/generate.ts @@ -68,6 +68,10 @@ export class CodegenOption { return this.stability; } + public codegenVersion(): CodeGenerationVersion { + return this.version; + } + public toString(): string { return `${this.version.toUpperCase()} (${this.stability})`; } @@ -118,6 +122,17 @@ export function defaultStability(language: Language): Stability { return stabilityLevelsFor(language)[0] ?? Stability.STABLE; } +/** + * The generator that produces `language` at `stability`, which a snapshot records so it is only + * ever a base for the same output. A level the language does not offer falls back to the + * generator it does have, as `defaultStability` does for the level. + */ +export function codegenVersionFor(language: Language, stability: Stability): CodeGenerationVersion { + const options = codegenOptionsFor(language); + const option = options.find((candidate) => candidate.stabilityLevel() === stability) ?? options[0]; + return option?.codegenVersion() ?? CodeGenerationVersion.V4; +} + /** * The name a language is shown under everywhere, so one reads the same in every message. * Takes a string because a config file names its own languages, and may name anything. diff --git a/src/types/sdk/generation-record.ts b/src/types/sdk/generation-record.ts index 0a0e33d80..5f9d6c405 100644 --- a/src/types/sdk/generation-record.ts +++ b/src/types/sdk/generation-record.ts @@ -92,6 +92,10 @@ export class GenerationRecord { return this.pristine; } + public generatedAs(): SdkGeneration { + return this.generation; + } + public conflictedPaths(): readonly string[] { return this.conflicts; } diff --git a/src/types/sdk/sdk-destination.ts b/src/types/sdk/sdk-destination.ts new file mode 100644 index 000000000..72e5c5f2e --- /dev/null +++ b/src/types/sdk/sdk-destination.ts @@ -0,0 +1,12 @@ +import { CommitId } from './commit-id.js'; +import { SdkRepository } from './sdk-repository.js'; + +/** + * How a run will write the SDK directory, decided before anything is uploaded: replaced as + * today, when nothing can be kept; replaced and then recorded, so the next run has a base; or + * merged over the developer's changes against the base the generation record names. + */ +export type SdkDestination = + | { kind: 'overwrite'; zip: boolean } + | { kind: 'record'; repository: SdkRepository | undefined } + | { kind: 'merge'; repository: SdkRepository; base: CommitId }; diff --git a/test/actions/sdk/generate.test.ts b/test/actions/sdk/generate.test.ts index 8fc68c54d..7ec74a1d8 100644 --- a/test/actions/sdk/generate.test.ts +++ b/test/actions/sdk/generate.test.ts @@ -7,9 +7,12 @@ import { expect } from 'chai'; import { err, ok } from 'neverthrow'; import sinon from 'sinon'; import { GenerateAction } from '../../../src/actions/sdk/generate.js'; +import { GitService } from '../../../src/infrastructure/git-service.js'; import { SdkGenerationService } from '../../../src/infrastructure/services/sdk-generation-service.js'; import { ZipService } from '../../../src/infrastructure/zip-service.js'; import { SdkGeneratePrompts } from '../../../src/prompts/sdk/generate.js'; +import { SdkPreparePrompts } from '../../../src/prompts/sdk/prepare.js'; +import { SdkWritePrompts } from '../../../src/prompts/sdk/write.js'; import { CommandMetadata } from '../../../src/types/common/command-metadata.js'; import { DirectoryPath } from '../../../src/types/file/directoryPath.js'; import { FileName } from '../../../src/types/file/fileName.js'; @@ -17,6 +20,8 @@ import { FilePath } from '../../../src/types/file/filePath.js'; import { FileProblem } from '../../../src/types/file/file-problem.js'; import { ProjectContext } from '../../../src/types/project-context.js'; import { Language, Stability } from '../../../src/types/sdk/generate.js'; +import { GenerationRecord } from '../../../src/types/sdk/generation-record.js'; +import { SnapshotRef } from '../../../src/types/sdk/snapshot-ref.js'; import { TempContext } from '../../../src/types/temp-context.js'; const COMMAND_METADATA: CommandMetadata = { commandName: 'sdk generate', shell: 'test' }; @@ -27,16 +32,19 @@ describe('GenerateAction (sdk)', () => { let sdkArchive: Buffer; let generateSdk: sinon.SinonStub; - const execute = (zipSdk = false) => + const execute = (options: { zip?: boolean; force?: boolean } = {}) => new GenerateAction(new DirectoryPath(root), COMMAND_METADATA, 'auth-key').execute( ProjectContext.in(new DirectoryPath(root)), sdkDirectory, Language.PYTHON, Stability.STABLE, - true, - zipSdk + options.force ?? true, + options.zip ?? false ); + const sdk = () => sdkDirectory.join('python'); + const at = (relative: string) => path.join(sdk().toString(), relative); + beforeEach(async () => { root = fs.mkdtempSync(path.join(os.tmpdir(), 'sdk-generate-')); fs.mkdirSync(path.join(root, 'src', 'spec'), { recursive: true }); @@ -61,12 +69,6 @@ describe('GenerateAction (sdk)', () => { fs.rmSync(root, { recursive: true, force: true }); }); - it('expands the generated SDK into its language directory', async () => { - expect((await execute()).isSuccess()).to.be.true; - - expect(fs.readFileSync(path.join(sdkDirectory.toString(), 'python', 'README.md'), 'utf-8')).to.equal('# sdk'); - }); - it('reports a source directory it could not zip, without generating', async () => { const problem: FileProblem = { kind: 'zipFailed', reason: 'EACCES: permission denied' }; sinon.stub(TempContext.prototype, 'zip').resolves(err(problem)); @@ -90,15 +92,53 @@ describe('GenerateAction (sdk)', () => { it('reports an SDK it could not save, naming its language directory', async () => { const archive = sinon.stub(ZipService.prototype, 'archive').callThrough(); archive.onSecondCall().resolves(err('ENOSPC: no space left on device, write')); - const sdkNotSaved = sinon.stub(SdkGeneratePrompts.prototype, 'sdkNotSaved'); - const sdkGenerated = sinon.stub(SdkGeneratePrompts.prototype, 'sdkGenerated'); + const sdkNotSaved = sinon.stub(SdkWritePrompts.prototype, 'sdkNotSaved'); + const sdkGenerated = sinon.stub(SdkWritePrompts.prototype, 'sdkGenerated'); - expect((await execute(true)).isFailed()).to.be.true; + expect((await execute({ zip: true })).isFailed()).to.be.true; expect(sdkNotSaved.firstCall.args[0]).to.deep.equal({ kind: 'zipFailed', reason: 'ENOSPC: no space left on device, write', - sdkDirectory: sdkDirectory.join('python') + sdkDirectory: sdk() }); expect(sdkGenerated.called).to.be.false; }); + + it('writes a zip and nothing else with --zip', async () => { + expect((await execute({ zip: true })).isSuccess()).to.be.true; + + expect(fs.readdirSync(sdk().toString())).to.deep.equal(['python.zip']); + }); + + it('stops before generating when the developer declines to overwrite', async () => { + fs.mkdirSync(sdk().toString(), { recursive: true }); + fs.writeFileSync(at('README.md'), '# mine'); + sinon.stub(SdkPreparePrompts.prototype, 'overwriteSdk').resolves(false); + sinon.stub(SdkPreparePrompts.prototype, 'destinationDirNotEmpty'); + + expect((await execute({ force: false })).isCancelled()).to.be.true; + + expect(generateSdk.called).to.be.false; + expect(fs.readFileSync(at('README.md'), 'utf-8')).to.equal('# mine'); + }); + + it('expands the generated SDK into its language directory and records it for the next run', async () => { + sinon.stub(SdkWritePrompts.prototype, 'repositoryInitialised'); + + expect((await execute()).isSuccess()).to.be.true; + + expect(fs.readFileSync(at('README.md'), 'utf-8')).to.equal('# sdk'); + const record = GenerationRecord.parse(fs.readFileSync(at('.apimatic/generation.json'), 'utf-8'))!; + const pristine = await new GitService().resolveRef(sdk(), SnapshotRef.pristine('')); + expect(record.pristineCommit().isEqual(pristine!)).to.be.true; + }); + + it('writes the SDK as it always has when git is not there', async () => { + sinon.stub(GitService.prototype, 'version').resolves(undefined); + sinon.stub(SdkPreparePrompts.prototype, 'gitUnavailable'); + + expect((await execute()).isSuccess()).to.be.true; + + expect(fs.readdirSync(sdk().toString())).to.deep.equal(['README.md']); + }); }); diff --git a/test/actions/sdk/prepare.test.ts b/test/actions/sdk/prepare.test.ts new file mode 100644 index 000000000..3c3a576a2 --- /dev/null +++ b/test/actions/sdk/prepare.test.ts @@ -0,0 +1,187 @@ +import fs from 'fs'; +import os from 'os'; +import path from 'path'; +import { expect } from 'chai'; +import { err } from 'neverthrow'; +import sinon from 'sinon'; +import { SdkPrepareAction } from '../../../src/actions/sdk/prepare.js'; +import { GitService } from '../../../src/infrastructure/git-service.js'; +import { PristineStoreService } from '../../../src/infrastructure/pristine-store-service.js'; +import { SdkPreparePrompts } from '../../../src/prompts/sdk/prepare.js'; +import { DirectoryPath } from '../../../src/types/file/directoryPath.js'; +import { SdkContext } from '../../../src/types/sdk-context.js'; +import { CommitId } from '../../../src/types/sdk/commit-id.js'; +import { CodeGenerationVersion, Language, Stability } from '../../../src/types/sdk/generate.js'; +import { GenerationRecord, SdkGeneration } from '../../../src/types/sdk/generation-record.js'; +import { GitVersion } from '../../../src/types/sdk/git-version.js'; +import { SdkRepository } from '../../../src/types/sdk/sdk-repository.js'; + +const STABLE: SdkGeneration = { + language: Language.PYTHON, + codegenVersion: CodeGenerationVersion.V4, + stability: Stability.STABLE +}; +const BETA: SdkGeneration = { ...STABLE, stability: Stability.BETA }; + +describe('SdkPrepareAction', () => { + let root: string; + let sdkContext: SdkContext; + const git = new GitService(); + const store = new PristineStoreService(); + + const execute = (options: { force?: boolean; zip?: boolean; generation?: SdkGeneration } = {}) => + new SdkPrepareAction().execute( + sdkContext, + options.generation ?? STABLE, + options.force ?? true, + options.zip ?? false + ); + + const sdk = () => new DirectoryPath(path.join(root, 'sdk', 'python')); + const write = (relative: string, contents: string) => { + const target = path.join(sdk().toString(), relative); + fs.mkdirSync(path.dirname(target), { recursive: true }); + fs.writeFileSync(target, contents); + }; + + /** A recorded generation of `files`, so the directory has a base; `conflicts` as the last run left them. */ + const recorded = async (files: Record, conflicts: string[] = []): Promise => { + for (const [relative, contents] of Object.entries(files)) { + write(relative, contents); + } + await git.init(new DirectoryPath(root)); + const repository = (await store.findRepository(sdk()))!; + const tree = (await store.snapshotGenerated(repository, sdk()))._unsafeUnwrap(); + (await store.recordGeneration(repository, tree, STABLE, '0.0.0', conflicts))._unsafeUnwrap(); + return repository; + }; + + beforeEach(() => { + root = fs.mkdtempSync(path.join(os.tmpdir(), 'prepare-sdk-')); + sdkContext = new SdkContext(Language.PYTHON, new DirectoryPath(path.join(root, 'sdk'))); + }); + + afterEach(() => { + sinon.restore(); + fs.rmSync(root, { recursive: true, force: true }); + }); + + it('writes a zip without consulting git', async () => { + const version = sinon.stub(GitService.prototype, 'version'); + + expect((await execute({ zip: true })).getValue()).to.deep.equal({ kind: 'overwrite', zip: true }); + expect(version.called).to.be.false; + }); + + for (const [found, output] of [ + ['no git', undefined], + ['an older git', 'git version 2.30.2'] + ] as const) { + it(`with ${found}, warns that customizations will not persist and overwrites`, async () => { + sinon.stub(GitService.prototype, 'version').resolves(output === undefined ? undefined : GitVersion.parse(output)); + const gitUnavailable = sinon.stub(SdkPreparePrompts.prototype, 'gitUnavailable'); + + expect((await execute()).getValue()).to.deep.equal({ kind: 'overwrite', zip: false }); + expect(gitUnavailable.firstCall.args[0]?.toString()).to.equal(output === undefined ? undefined : '2.30.2'); + }); + } + + it('records into a repository still to be made when nothing encloses the SDK directory', async () => { + expect((await execute()).getValue()).to.deep.equal({ kind: 'record', repository: undefined }); + }); + + it('records into the enclosing repository when the directory has no record', async () => { + await git.init(new DirectoryPath(root)); + + const destination = (await execute()).getValue(); + + expect(destination.kind).to.equal('record'); + expect(destination.kind === 'record' && destination.repository?.root().toString()).to.equal( + new DirectoryPath(root).toString() + ); + }); + + it('records over an SDK generated at another stability, after saying so', async () => { + await recorded({ 'README.md': '# sdk' }); + const notForThis = sinon.stub(SdkPreparePrompts.prototype, 'recordNotForThisGeneration'); + + expect((await execute({ generation: BETA })).getValue().kind).to.equal('record'); + expect(notForThis.firstCall.args[0]).to.deep.equal(STABLE); + expect(notForThis.firstCall.args[1]).to.deep.equal(BETA); + }); + + it('records over an SDK whose snapshot the repository does not hold, naming the commit', async () => { + await recorded({ 'README.md': '# sdk' }); + const missing = CommitId.create('f'.repeat(40))!; + write('.apimatic/generation.json', GenerationRecord.written(STABLE, missing, new Date(), '0.0.0').toJson()); + const snapshotMissing = sinon.stub(SdkPreparePrompts.prototype, 'snapshotMissing'); + + expect((await execute()).getValue().kind).to.equal('record'); + expect(snapshotMissing.firstCall.args[0].isEqual(missing)).to.be.true; + }); + + it('merges against the recorded snapshot, without asking, when the record and its commit are there', async () => { + const repository = await recorded({ 'README.md': '# sdk' }); + write('README.md', '# edited'); + const overwriteSdk = sinon.stub(SdkPreparePrompts.prototype, 'overwriteSdk'); + + const destination = (await execute({ force: false })).getValue(); + + expect(destination.kind).to.equal('merge'); + expect(destination.kind === 'merge' && destination.repository.root().isEqual(repository.root())).to.be.true; + expect(destination.kind === 'merge' && destination.base.isEqual((await store.readRecord(sdk()))!.pristineCommit())) + .to.be.true; + expect(overwriteSdk.called).to.be.false; + }); + + it('refuses while a listed file still holds the markers', async () => { + await recorded({ 'README.md': '# sdk', 'a.py': 'x' }, ['a.py']); + write('a.py', '<<<<<<< Your changes\nmine\n=======\ntheirs\n>>>>>>> Generated by APIMatic\n'); + const conflictsUnresolved = sinon.stub(SdkPreparePrompts.prototype, 'conflictsUnresolved'); + + expect((await execute()).isFailed()).to.be.true; + expect(conflictsUnresolved.firstCall.args[0]).to.deep.equal(['a.py']); + }); + + it('refuses an SDK directory nested in one that has snapshots', async () => { + await recorded({ 'README.md': '# sdk' }); + sdkContext = new SdkContext(Language.PYTHON, sdk()); + const nestedSdkDirectory = sinon.stub(SdkPreparePrompts.prototype, 'nestedSdkDirectory'); + + expect((await execute()).isFailed()).to.be.true; + expect(nestedSdkDirectory.firstCall.args[0]).to.contain('refs/apimatic/pristine/sdk/python/python'); + }); + + it('reports a directory git could not inspect', async () => { + sinon.stub(PristineStoreService.prototype, 'inspect').resolves(err({ kind: 'gitFailed', reason: 'git broke' })); + const notInspected = sinon.stub(SdkPreparePrompts.prototype, 'directoryNotInspected'); + + expect((await execute()).isFailed()).to.be.true; + expect(notInspected.firstCall.args[0]).to.equal('git broke'); + }); + + describe('the overwrite question', () => { + beforeEach(() => write('README.md', '# mine')); + + it('is asked when the directory holds files and there is nothing to merge, and no cancels', async () => { + const overwriteSdk = sinon.stub(SdkPreparePrompts.prototype, 'overwriteSdk').resolves(false); + sinon.stub(SdkPreparePrompts.prototype, 'destinationDirNotEmpty'); + + expect((await execute({ force: false })).isCancelled()).to.be.true; + expect(overwriteSdk.firstCall.args[0].isEqual(sdk())).to.be.true; + }); + + it('goes ahead on yes', async () => { + sinon.stub(SdkPreparePrompts.prototype, 'overwriteSdk').resolves(true); + + expect((await execute({ force: false })).getValue().kind).to.equal('record'); + }); + + it('is skipped by --force', async () => { + const overwriteSdk = sinon.stub(SdkPreparePrompts.prototype, 'overwriteSdk'); + + expect((await execute({ force: true })).getValue().kind).to.equal('record'); + expect(overwriteSdk.called).to.be.false; + }); + }); +}); diff --git a/test/actions/sdk/write.test.ts b/test/actions/sdk/write.test.ts new file mode 100644 index 000000000..b308d300f --- /dev/null +++ b/test/actions/sdk/write.test.ts @@ -0,0 +1,228 @@ +import fs from 'fs'; +import os from 'os'; +import path from 'path'; +import { TextDecoder } from 'node:util'; +import { expect } from 'chai'; +import { err } from 'neverthrow'; +import sinon from 'sinon'; +import { SdkWriteAction } from '../../../src/actions/sdk/write.js'; +import { GENERATED_MARKER, YOURS_MARKER } from '../../../src/application/sdk-merge/markers.js'; +import { GitService } from '../../../src/infrastructure/git-service.js'; +import { PristineStoreService } from '../../../src/infrastructure/pristine-store-service.js'; +import { ZipService } from '../../../src/infrastructure/zip-service.js'; +import { SdkWritePrompts } from '../../../src/prompts/sdk/write.js'; +import { DirectoryPath } from '../../../src/types/file/directoryPath.js'; +import { SdkContext } from '../../../src/types/sdk-context.js'; +import { CommitId } from '../../../src/types/sdk/commit-id.js'; +import { CodeGenerationVersion, Language, Stability } from '../../../src/types/sdk/generate.js'; +import { GenerationRecord, SdkGeneration } from '../../../src/types/sdk/generation-record.js'; +import { SdkDestination } from '../../../src/types/sdk/sdk-destination.js'; +import { SdkRepository } from '../../../src/types/sdk/sdk-repository.js'; +import { SnapshotRef } from '../../../src/types/sdk/snapshot-ref.js'; + +const STABLE: SdkGeneration = { + language: Language.PYTHON, + codegenVersion: CodeGenerationVersion.V4, + stability: Stability.STABLE +}; + +const MODEL_V1 = [ + 'class Book:', + ' id: str', + ' title: str', + ' author: str', + ' year: int', + ' # Price in USD.', + ' price: float', + '' +].join('\n'); +const MODEL_V2 = MODEL_V1.replace('# Price in USD.', '# Retail price in US dollars.') + ' isbn: str\n'; +const DECORATED = 'from dataclasses import dataclass\n\n\n@dataclass\n'; +const MODEL_V1_EDITED = MODEL_V1.replace('# Price in USD.', '# Price in USD, VAT included.'); + +type Files = Record; + +describe('SdkWriteAction', () => { + let root: string; + let sdkContext: SdkContext; + let generations = 0; + const git = new GitService(); + const store = new PristineStoreService(); + + const sdk = () => new DirectoryPath(path.join(root, 'sdk', 'python')); + const at = (relative: string) => path.join(sdk().toString(), relative); + const read = (relative: string) => fs.readFileSync(at(relative), 'utf-8'); + const write = (directory: DirectoryPath, files: Files) => { + for (const [relative, contents] of Object.entries(files)) { + const target = path.join(directory.toString(), relative); + fs.mkdirSync(path.dirname(target), { recursive: true }); + fs.writeFileSync(target, contents); + } + }; + const generated = (files: Files): DirectoryPath => { + const directory = new DirectoryPath(path.join(root, `generated-${generations++}`)); + write(directory, files); + return directory; + }; + const record = () => GenerationRecord.parse(read('.apimatic/generation.json'))!; + const execute = (destination: SdkDestination, files: Files) => + new SdkWriteAction().execute(destination, sdkContext, generated(files), STABLE); + + /** A first generation of `files`, written and recorded into the project's repository. */ + const recorded = async (files: Files): Promise<{ repository: SdkRepository; base: CommitId }> => { + write(sdk(), files); + await git.init(new DirectoryPath(root)); + const repository = (await store.findRepository(sdk()))!; + const tree = (await store.snapshotGenerated(repository, generated(files)))._unsafeUnwrap(); + const written = (await store.recordGeneration(repository, tree, STABLE, '0.0.0', []))._unsafeUnwrap(); + return { repository, base: written.pristineCommit() }; + }; + + beforeEach(() => { + root = fs.mkdtempSync(path.join(os.tmpdir(), 'sdk-write-')); + sdkContext = new SdkContext(Language.PYTHON, new DirectoryPath(path.join(root, 'sdk'))); + }); + + afterEach(() => { + sinon.restore(); + fs.rmSync(root, { recursive: true, force: true }); + }); + + describe('overwrite', () => { + it('replaces the directory with the generated files, keeping a .git and writing no record', async () => { + write(sdk(), { 'old.py': 'old', '.git/HEAD': 'ref: refs/heads/main' }); + const sdkGenerated = sinon.stub(SdkWritePrompts.prototype, 'sdkGenerated'); + + expect((await execute({ kind: 'overwrite', zip: false }, { 'README.md': '# sdk' })).isSuccess()).to.be.true; + + expect(fs.readdirSync(sdk().toString()).sort()).to.deep.equal(['.git', 'README.md']); + expect(sdkGenerated.firstCall.args[0].isEqual(sdk())).to.be.true; + }); + + it('writes a zip', async () => { + expect((await execute({ kind: 'overwrite', zip: true }, { 'README.md': '# sdk' })).isSuccess()).to.be.true; + + expect(fs.readdirSync(sdk().toString())).to.deep.equal(['python.zip']); + }); + + it('reports an SDK it could not save', async () => { + sinon.stub(ZipService.prototype, 'archive').resolves(err('ENOSPC: no space left on device, write')); + const sdkNotSaved = sinon.stub(SdkWritePrompts.prototype, 'sdkNotSaved'); + + expect((await execute({ kind: 'overwrite', zip: true }, { 'README.md': '# sdk' })).isFailed()).to.be.true; + expect(sdkNotSaved.firstCall.args[0]).to.deep.equal({ + kind: 'zipFailed', + reason: 'ENOSPC: no space left on device, write', + sdkDirectory: sdk() + }); + }); + }); + + describe('record', () => { + it('initialises a repository in the SDK directory, says so, and records the snapshot', async () => { + const repositoryInitialised = sinon.stub(SdkWritePrompts.prototype, 'repositoryInitialised'); + + const result = await execute({ kind: 'record', repository: undefined }, { 'README.md': '# sdk', 'a.py': 'x' }); + + expect(result.isSuccess()).to.be.true; + expect(repositoryInitialised.firstCall.args[0].isEqual(sdk())).to.be.true; + expect(fs.readFileSync(at('.git/HEAD'), 'utf-8').trim()).to.equal('ref: refs/heads/main'); + const pristine = (await git.resolveRef(sdk(), SnapshotRef.pristine('')))!; + expect(record().pristineCommit().isEqual(pristine)).to.be.true; + expect(record().isFor(STABLE)).to.be.true; + const repository = (await store.findRepository(sdk()))!; + const files = (await store.readSnapshot(repository, pristine))._unsafeUnwrap(); + expect([...files.keys()]).to.have.members(['README.md', 'a.py']); + }); + + it('records into the enclosing repository under the SDK directory’s ref, keeping what was there out of it', async () => { + write(sdk(), { 'old.py': 'old' }); + await git.init(new DirectoryPath(root)); + const repository = (await store.findRepository(sdk()))!; + const repositoryInitialised = sinon.stub(SdkWritePrompts.prototype, 'repositoryInitialised'); + + expect((await execute({ kind: 'record', repository }, { 'README.md': '# sdk' })).isSuccess()).to.be.true; + + expect(repositoryInitialised.called).to.be.false; + expect(fs.existsSync(at('.git'))).to.be.false; + expect(fs.existsSync(at('old.py'))).to.be.false; + const ref = SnapshotRef.pristine('sdk/python'); + expect((await git.resolveRef(new DirectoryPath(root), ref))?.isEqual(record().pristineCommit())).to.be.true; + }); + + it('still succeeds, with a warning, when the snapshot cannot be recorded', async () => { + sinon + .stub(PristineStoreService.prototype, 'recordGeneration') + .resolves(err({ kind: 'gitFailed', reason: 'git broke' })); + const snapshotNotRecorded = sinon.stub(SdkWritePrompts.prototype, 'snapshotNotRecorded'); + + expect((await execute({ kind: 'record', repository: undefined }, { 'README.md': '# sdk' })).isSuccess()).to.be + .true; + + expect(read('README.md')).to.equal('# sdk'); + expect(snapshotNotRecorded.firstCall.args[0]).to.equal('git broke'); + }); + }); + + describe('merge', () => { + it('merges the generator’s changes into your edited files, adds new files and keeps your own', async () => { + const { repository, base } = await recorded({ 'README.md': '# sdk', 'models/book.py': MODEL_V1 }); + write(sdk(), { 'models/book.py': DECORATED + MODEL_V1, 'notes.txt': 'mine' }); + const sdkMerged = sinon.stub(SdkWritePrompts.prototype, 'sdkMerged'); + + const result = await execute( + { kind: 'merge', repository, base }, + { 'README.md': '# sdk v2', 'models/book.py': MODEL_V2, 'models/author.py': 'class Author: ...' } + ); + + expect(result.isSuccess()).to.be.true; + expect(read('models/book.py')).to.equal(DECORATED + MODEL_V2); + expect(read('README.md')).to.equal('# sdk v2'); + expect(read('models/author.py')).to.equal('class Author: ...'); + expect(read('notes.txt')).to.equal('mine'); + expect(sdkMerged.firstCall.args[1].counts).to.include({ added: 1, updated: 1, merged: 1, removed: 0, kept: 0 }); + expect(record().pristineCommit().isEqual(base)).to.be.false; + expect(record().hasConflicts()).to.be.false; + expect((await git.describe(repository.root(), record().pristineCommit())).parents).to.deep.equal([base]); + const previous = (await git.resolveRef(repository.root(), repository.previousRef()))!; + const kept = (await store.readSnapshot(repository, previous))._unsafeUnwrap(); + expect(new TextDecoder().decode(kept.get('models/book.py'))).to.equal(DECORATED + MODEL_V1); + }); + + it('writes markers where you and the generator changed the same lines, lists the file and fails', async () => { + const { repository, base } = await recorded({ 'README.md': '# sdk', 'models/book.py': MODEL_V1 }); + write(sdk(), { 'models/book.py': MODEL_V1_EDITED }); + const conflicts = sinon.stub(SdkWritePrompts.prototype, 'conflicts'); + + const result = await execute( + { kind: 'merge', repository, base }, + { 'README.md': '# sdk v2', 'models/book.py': MODEL_V2 } + ); + + expect(result.isFailed()).to.be.true; + expect(result.getExitCode()).to.equal(1); + expect(result.getMessage()).to.equal('Resolve the conflicts and rerun'); + expect(conflicts.firstCall.args[0]).to.deep.equal([{ path: 'models/book.py', kind: 'bothChanged' }]); + expect(read('models/book.py')).to.contain(`${YOURS_MARKER}\n # Price in USD, VAT included.\n=======\n`); + expect(read('models/book.py')).to.contain(' # Retail price in US dollars.\n'); + expect(read('models/book.py')).to.contain(`${GENERATED_MARKER}\n`); + expect(read('README.md')).to.equal('# sdk v2'); + expect(record().conflictedPaths()).to.deep.equal(['models/book.py']); + }); + + it('leaves the directory alone when the pre-run state cannot be kept', async () => { + const { repository, base } = await recorded({ 'README.md': '# sdk' }); + write(sdk(), { 'README.md': '# edited' }); + sinon + .stub(PristineStoreService.prototype, 'savePrevious') + .resolves(err({ kind: 'gitFailed', reason: 'git broke' })); + const customCodeFailed = sinon.stub(SdkWritePrompts.prototype, 'customCodeFailed'); + + expect((await execute({ kind: 'merge', repository, base }, { 'README.md': '# sdk v2' })).isFailed()).to.be.true; + + expect(read('README.md')).to.equal('# edited'); + expect(record().pristineCommit().isEqual(base)).to.be.true; + expect(customCodeFailed.firstCall.args[0]).to.equal('git broke'); + }); + }); +}); diff --git a/test/infrastructure/pristine-store-service.test.ts b/test/infrastructure/pristine-store-service.test.ts index b8e973d80..2936789e2 100644 --- a/test/infrastructure/pristine-store-service.test.ts +++ b/test/infrastructure/pristine-store-service.test.ts @@ -68,6 +68,17 @@ describe('PristineStoreService', () => { expect(await service.findRepository(sdk)).to.be.undefined; }); + it('answers for an SDK directory that is not there yet, from its nearest existing ancestor', async () => { + const projectDirectory = new DirectoryPath(path.join(root, 'project')); + fs.mkdirSync(projectDirectory.toString()); + await git.init(projectDirectory); + + const repository = (await service.findRepository(projectDirectory.join('sdk', 'typescript')))!; + + expect(repository.root().isEqual(projectDirectory)).to.be.true; + expect(repository.pristineRef().toString()).to.equal('refs/apimatic/pristine/sdk/typescript'); + }); + it('answers with the enclosing repository, however deep the SDK directory sits', async () => { const repository = await project(path.join('packages', 'sdk', 'typescript')); @@ -78,6 +89,15 @@ describe('PristineStoreService', () => { }); describe('initRepository', () => { + it('creates the SDK directory when it is not there yet', async () => { + const sdk = new DirectoryPath(path.join(root, 'sdk', 'typescript')); + + const repository = (await service.initRepository(sdk))._unsafeUnwrap(); + + expect(repository.isRootSdk()).to.be.true; + expect(fs.existsSync(path.join(sdk.toString(), '.git'))).to.be.true; + }); + it('makes the SDK directory its own repository, on main, leaving its files alone', async () => { const sdk = new DirectoryPath(path.join(root, 'sdk', 'typescript')); write(sdk, 'README.md', '# sdk'); @@ -217,7 +237,7 @@ describe('PristineStoreService', () => { 'src/index.ts' ]); - const file = await service.writeRecord(sdk, written); + const file = (await service.writeRecord(sdk, written))._unsafeUnwrap(); expect(file.toString()).to.equal(path.join(sdk.toString(), '.apimatic', 'generation.json')); const read = (await service.readRecord(sdk))!; @@ -233,5 +253,104 @@ describe('PristineStoreService', () => { expect(await service.readRecord(repository.sdkDirectory())).to.be.undefined; }); + + it('is written after the snapshot it names, in one step', async () => { + const repository = await project(); + const tree = (await service.snapshotGenerated(repository, generated({ 'README.md': '# v1' })))._unsafeUnwrap(); + + const recorded = ( + await service.recordGeneration(repository, tree, TYPESCRIPT, '2.3.0', ['src/index.ts']) + )._unsafeUnwrap(); + + const read = (await service.readRecord(repository.sdkDirectory()))!; + expect(read.toJson()).to.equal(recorded.toJson()); + expect((await service.pristineTip(repository))?.isEqual(read.pristineCommit())).to.be.true; + }); + }); + + describe('inspect', () => { + const MARKED = 'one\n<<<<<<< Your changes\nmine\n=======\ntheirs\n>>>>>>> Generated by APIMatic\n'; + + it('finds no repository', async () => { + const sdk = new DirectoryPath(path.join(root, 'sdk', 'typescript')); + + expect((await service.inspect(sdk, TYPESCRIPT))._unsafeUnwrap()).to.deep.equal({ kind: 'noRepository' }); + }); + + it('finds a repository but no record', async () => { + const repository = await project(); + + const state = (await service.inspect(repository.sdkDirectory(), TYPESCRIPT))._unsafeUnwrap(); + + expect(state.kind).to.equal('noRecord'); + expect(state.kind === 'noRecord' && state.repository.root().isEqual(repository.root())).to.be.true; + }); + + it('finds a record for another generation', async () => { + const repository = await project(); + const pristine = (await record(repository, { 'README.md': '# v1' }))._unsafeUnwrap(); + await service.writeRecord( + repository.sdkDirectory(), + GenerationRecord.written({ ...TYPESCRIPT, stability: Stability.BETA }, pristine, new Date(), '2.3.0') + ); + + const state = (await service.inspect(repository.sdkDirectory(), TYPESCRIPT))._unsafeUnwrap(); + + expect(state.kind).to.equal('otherGeneration'); + expect(state.kind === 'otherGeneration' && state.recorded.stability).to.equal(Stability.BETA); + }); + + it('finds a record whose snapshot the repository does not hold', async () => { + const repository = await project(); + const missing = CommitId.create('f'.repeat(40))!; + await service.writeRecord( + repository.sdkDirectory(), + GenerationRecord.written(TYPESCRIPT, missing, new Date(), '2.3.0') + ); + + const state = (await service.inspect(repository.sdkDirectory(), TYPESCRIPT))._unsafeUnwrap(); + + expect(state.kind).to.equal('snapshotMissing'); + expect(state.kind === 'snapshotMissing' && state.commit.isEqual(missing)).to.be.true; + }); + + it('names the listed files that still hold conflict markers, and not the resolved or removed ones', async () => { + const repository = await project(); + const sdk = repository.sdkDirectory(); + const pristine = (await record(repository, { 'README.md': '# v1' }))._unsafeUnwrap(); + await service.writeRecord( + sdk, + GenerationRecord.written(TYPESCRIPT, pristine, new Date(), '2.3.0', ['src/a.ts', 'src/b.ts', 'src/gone.ts']) + ); + write(sdk, 'src/a.ts', MARKED); + write(sdk, 'src/b.ts', 'resolved\n'); + + const state = (await service.inspect(sdk, TYPESCRIPT))._unsafeUnwrap(); + + expect(state).to.include({ kind: 'unresolved' }); + expect(state.kind === 'unresolved' && state.paths).to.deep.equal(['src/a.ts']); + }); + + it('is ready to merge against the recorded snapshot once the markers are gone', async () => { + const repository = await project(); + const sdk = repository.sdkDirectory(); + const pristine = (await record(repository, { 'README.md': '# v1' }))._unsafeUnwrap(); + await service.writeRecord(sdk, GenerationRecord.written(TYPESCRIPT, pristine, new Date(), '2.3.0', ['src/a.ts'])); + write(sdk, 'src/a.ts', 'resolved\n'); + + const state = (await service.inspect(sdk, TYPESCRIPT))._unsafeUnwrap(); + + expect(state.kind).to.equal('ready'); + expect(state.kind === 'ready' && state.base.isEqual(pristine)).to.be.true; + }); + + it('refuses an SDK directory nested in one that has snapshots', async () => { + const outer = await project(); + (await record(outer, { 'README.md': '# outer' }))._unsafeUnwrap(); + + const problem = (await service.inspect(outer.sdkDirectory().join('typescript'), TYPESCRIPT))._unsafeUnwrapErr(); + + expect(problem.kind).to.equal('nestedSdkDirectory'); + }); }); }); diff --git a/test/types/sdk-context.test.ts b/test/types/sdk-context.test.ts index fe98f11c3..032085b66 100644 --- a/test/types/sdk-context.test.ts +++ b/test/types/sdk-context.test.ts @@ -32,6 +32,20 @@ describe('SdkContext', () => { expect(fs.existsSync(inRoot('sdk', 'python', 'python.zip'))).to.be.true; }); + it('replaces what the language directory holds, except its .git', async () => { + fs.mkdirSync(inRoot('generated')); + fs.writeFileSync(inRoot('generated', 'README.md'), '# sdk'); + fs.mkdirSync(inRoot('sdk', 'python', '.git'), { recursive: true }); + fs.writeFileSync(inRoot('sdk', 'python', '.git', 'HEAD'), 'ref: refs/heads/main'); + fs.writeFileSync(inRoot('sdk', 'python', 'old.py'), 'old'); + + const saved = await context.save(new DirectoryPath(inRoot('generated')), false); + + expect(saved._unsafeUnwrap().toString()).to.equal(inRoot('sdk', 'python')); + expect(fs.readdirSync(inRoot('sdk', 'python')).sort()).to.deep.equal(['.git', 'README.md']); + expect(fs.readFileSync(inRoot('sdk', 'python', '.git', 'HEAD'), 'utf-8')).to.equal('ref: refs/heads/main'); + }); + it('reports an SDK it could not zip, naming its language directory, and leaves no archive behind', async () => { const saved = await context.save(new DirectoryPath(inRoot('missing')), true); diff --git a/test/types/sdk/generate.test.ts b/test/types/sdk/generate.test.ts index e309fa4d4..13299ec30 100644 --- a/test/types/sdk/generate.test.ts +++ b/test/types/sdk/generate.test.ts @@ -1,9 +1,12 @@ import { expect } from 'chai'; import { AVAILABLE_LANGUAGES, + CodeGenerationVersion, + codegenVersionFor, isAvailableLanguage, Language, LANGUAGE_CHOICES, + Stability, UPCOMING_LANGUAGES } from '../../../src/types/sdk/generate'; @@ -33,3 +36,13 @@ describe('the languages a generator renders', () => { expect(isAvailableLanguage('cobol')).to.be.false; }); }); + +describe('the generator behind a language and level', () => { + it('is the one the table offers for that level', () => { + expect(codegenVersionFor(Language.PYTHON, Stability.BETA)).to.equal(CodeGenerationVersion.V4); + }); + + it('falls back to the generator the language does have, as the default level does', () => { + expect(codegenVersionFor(Language.JAVA, Stability.STABLE)).to.equal(CodeGenerationVersion.V4); + }); +}); From 874539171abf230289dedd627f66e74203f71303 Mon Sep 17 00:00:00 2001 From: Asad Ali <14asadali@gmail.com> Date: Tue, 20 Oct 2026 14:53:55 +0500 Subject: [PATCH 04/29] feat(sdk): mark conflicts in git's index and explain a missing snapshot A conflicted regeneration now puts git's three versions of each conflicted path into the repository's index through `update-index --index-info`, so `git status` reports them as both modified and `git mergetool` opens them, and the command tracks a telemetry event with the counts by kind. An SDK whose recorded snapshot the repository does not hold is told which commit is missing and how a teammate pushes and the reader fetches the `refs/apimatic/pristine/*` refs before it is replaced. Co-Authored-By: Claude Fable 5.1 --- src/actions/sdk/generate.ts | 3 +- src/actions/sdk/write.ts | 17 ++++++---- src/application/sdk-merge/merge-plan.ts | 15 +++++++++ src/commands/sdk/generate.ts | 22 +++++++++++++ src/infrastructure/git-service.ts | 19 +++++++++++ src/infrastructure/merge-service.ts | 20 +++++++++-- src/prompts/sdk/prepare.ts | 8 +++-- src/prompts/sdk/write.ts | 10 +++++- src/types/events/sdk-merge-conflicted.ts | 13 ++++++++ src/types/sdk/sdk-repository.ts | 6 ++++ test/actions/sdk/prepare.test.ts | 33 +++++++++++++++++++ test/actions/sdk/write.test.ts | 25 ++++++++++++++ .../types/events/sdk-merge-conflicted.test.ts | 25 ++++++++++++++ 13 files changed, 203 insertions(+), 13 deletions(-) create mode 100644 src/types/events/sdk-merge-conflicted.ts create mode 100644 test/types/events/sdk-merge-conflicted.test.ts diff --git a/src/actions/sdk/generate.ts b/src/actions/sdk/generate.ts index 4d00075f1..b2b930647 100644 --- a/src/actions/sdk/generate.ts +++ b/src/actions/sdk/generate.ts @@ -1,3 +1,4 @@ +import { MergePlan } from '../../application/sdk-merge/merge-plan.js'; import { SdkGenerationService } from '../../infrastructure/services/sdk-generation-service.js'; import { DirectoryPath } from '../../types/file/directoryPath.js'; import { ActionResult } from '../action-result.js'; @@ -33,7 +34,7 @@ export class GenerateAction { zipSdk: boolean, apiVersion?: string, packageSettingsDirectory?: DirectoryPath - ): Promise => { + ): Promise> => { if (!isAvailableLanguage(language)) { this.prompts.languageNotAvailable(language); return ActionResult.failed(); diff --git a/src/actions/sdk/write.ts b/src/actions/sdk/write.ts index adfc35c59..127ae26cb 100644 --- a/src/actions/sdk/write.ts +++ b/src/actions/sdk/write.ts @@ -1,4 +1,5 @@ import { err, ok, Result } from 'neverthrow'; +import { MergePlan } from '../../application/sdk-merge/merge-plan.js'; import { envInfo } from '../../infrastructure/env-info.js'; import { MergeService } from '../../infrastructure/merge-service.js'; import { PristineStoreProblem, PristineStoreService } from '../../infrastructure/pristine-store-service.js'; @@ -25,7 +26,7 @@ export class SdkWriteAction { sdkContext: SdkContext, tempSdk: DirectoryPath, generation: SdkGeneration - ): Promise => { + ): Promise> => { switch (destination.kind) { case 'overwrite': return this.overwrite(sdkContext, tempSdk, destination.zip); @@ -79,7 +80,7 @@ export class SdkWriteAction { sdkContext: SdkContext, tempSdk: DirectoryPath, generation: SdkGeneration - ): Promise { + ): Promise> { const generated = await this.store.snapshotGenerated(repository, tempSdk); if (generated.isErr()) { return this.customCodeFailed(generated.error); @@ -103,11 +104,15 @@ export class SdkWriteAction { } await this.record(repository, generated, generation, plan.conflictedPaths()); this.prompts.sdkMerged(sdkContext.directory(), plan); - if (plan.hasConflicts()) { - this.prompts.conflicts(plan.conflicts, repository); - return ActionResult.failed(MERGE_NEEDS_RESOLVING); + if (!plan.hasConflicts()) { + return ActionResult.success(); } - return ActionResult.success(); + const staged = await this.mergeService.stageConflicts(repository, merged.value); + if (staged.isErr()) { + this.prompts.conflictsNotStaged(staged.error.reason); + } + this.prompts.conflicts(plan.conflicts, repository); + return ActionResult.failed(MERGE_NEEDS_RESOLVING, plan); } private customCodeFailed(problem: { reason: string }): ActionResult { diff --git a/src/application/sdk-merge/merge-plan.ts b/src/application/sdk-merge/merge-plan.ts index 1988b32ed..fdbffd80a 100644 --- a/src/application/sdk-merge/merge-plan.ts +++ b/src/application/sdk-merge/merge-plan.ts @@ -67,6 +67,21 @@ export class MergePlan { return this.conflicts.map((conflict) => conflict.path); } + public conflictsByKind(): Record { + const counts: Record = { + bothChanged: 0, + bothAdded: 0, + deletedByYou: 0, + noLongerGenerated: 0, + binary: 0, + other: 0 + }; + for (const conflict of this.conflicts) { + counts[conflict.kind] += 1; + } + return counts; + } + /** Whether the file at `path` is written with conflict markers in it. */ public hasMarkers(path: string): boolean { return this.conflicts.some( diff --git a/src/commands/sdk/generate.ts b/src/commands/sdk/generate.ts index 5ccf62d0b..ecf7e89aa 100644 --- a/src/commands/sdk/generate.ts +++ b/src/commands/sdk/generate.ts @@ -14,6 +14,8 @@ import { StabilityChoice } from '../../types/sdk/stability-choice.js'; import { CommandMetadata } from '../../types/common/command-metadata.js'; import { ProjectContext } from '../../types/project-context.js'; import { format, intro, outro } from '../../prompts/format.js'; +import { TelemetryService } from '../../infrastructure/services/telemetry-service.js'; +import { SdkMergeConflictedEvent } from '../../types/events/sdk-merge-conflicted.js'; import { listedInProse } from '../../utils/string-utils.js'; const inProse = (languages: readonly Language[]) => listedInProse(languages.map(languageLabel)); @@ -102,6 +104,26 @@ ${inProse(AVAILABLE_LANGUAGES)} are available; ${inProse(UPCOMING_LANGUAGES)} ar apiVersion ); outro(result); + + await result.mapAll( + () => {}, + async () => { + const conflicts = result.getError(); + if (conflicts === undefined) { + return; + } + await new TelemetryService(this.getConfigDir()).trackEvent( + new SdkMergeConflictedEvent(conflicts.conflictsByKind(), SdkGenerate.id, { + language, + ...(force && { force }), + ...(stability && { stability }), + ...(apiVersion && { 'api-version': apiVersion }) + }), + commandMetadata.shell + ); + }, + () => {} + ); } private readonly getConfigDir = () => { diff --git a/src/infrastructure/git-service.ts b/src/infrastructure/git-service.ts index c8af882a8..c1bc43103 100644 --- a/src/infrastructure/git-service.ts +++ b/src/infrastructure/git-service.ts @@ -2,6 +2,7 @@ import { Buffer } from 'node:buffer'; import fs from 'node:fs'; import path from 'node:path'; import { execa, ExecaError } from 'execa'; +import { StageEntry } from '../application/sdk-merge/merge-tree-output.js'; import { DirectoryPath } from '../types/file/directoryPath.js'; import { FileName } from '../types/file/fileName.js'; import { FilePath } from '../types/file/filePath.js'; @@ -241,6 +242,24 @@ export class GitService { return entries; } + /** + * Puts the versions of each conflicted path into the repository's own index, as a merge + * would, so `git status` reports it as both modified and `git mergetool` can open it. Paths + * are from the repository root. A stage-0 entry is dropped first, since git would otherwise + * keep it beside the others. + */ + public async stageConflicts(root: DirectoryPath, entries: readonly StageEntry[]): Promise { + const removals = [...new Set(entries.map((entry) => entry.path))].map((path) => `0 ${'0'.repeat(40)} 0\t${path}`); + const stages = entries.map((entry) => `${entry.mode} ${entry.oid} ${entry.stage}\t${entry.path}`); + await this.run( + root, + ['update-index', '-z', '--index-info'], + {}, + undefined, + `${[...removals, ...stages].join('\0')}\0` + ); + } + public static emptyTree(): TreeId { return TreeId.fromGit(EMPTY_TREE); } diff --git a/src/infrastructure/merge-service.ts b/src/infrastructure/merge-service.ts index c5c944a2d..820f76d15 100644 --- a/src/infrastructure/merge-service.ts +++ b/src/infrastructure/merge-service.ts @@ -3,7 +3,7 @@ import path from 'node:path'; import { err, ok, Result } from 'neverthrow'; import { MergePlan } from '../application/sdk-merge/merge-plan.js'; import { relabelMarkers } from '../application/sdk-merge/markers.js'; -import { parseMergeTreeOutput } from '../application/sdk-merge/merge-tree-output.js'; +import { parseMergeTreeOutput, StageEntry } from '../application/sdk-merge/merge-tree-output.js'; import { planMerge } from '../application/sdk-merge/sdk-merge.js'; import { CommitId } from '../types/sdk/commit-id.js'; import { SdkRepository } from '../types/sdk/sdk-repository.js'; @@ -13,7 +13,8 @@ import { GitService } from './git-service.js'; export type MergeProblem = { kind: 'gitFailed'; reason: string }; -export type MergeOutcome = { plan: MergePlan; merged: TreeId }; +/** The plan, the merged tree, and git's versions of every conflicted path, below the SDK directory. */ +export type MergeOutcome = { plan: MergePlan; merged: TreeId; stages: StageEntry[] }; /** * The three-way merge of an SDK directory, done by git: the disk and the fresh generation are @@ -44,7 +45,7 @@ export class MergeService { applied: await this.git.diffTrees(root, disk, merged, false), basePaths: (await this.git.listTree(root, baseTree)).map((entry) => entry.path) }); - return ok({ plan, merged }); + return ok({ plan, merged, stages: output.stages }); } catch (error) { return err({ kind: 'gitFailed', reason: errorMessage(error) }); } @@ -69,4 +70,17 @@ export class MergeService { return err({ kind: 'gitFailed', reason: errorMessage(error) }); } } + + /** Marks the conflicted paths in the repository's index the way a merge would, so git's own tools take over. */ + public async stageConflicts(repository: SdkRepository, outcome: MergeOutcome): Promise> { + try { + await this.git.stageConflicts( + repository.root(), + outcome.stages.map((entry) => ({ ...entry, path: repository.rootRelative(entry.path) })) + ); + return ok(undefined); + } catch (error) { + return err({ kind: 'gitFailed', reason: errorMessage(error) }); + } + } } diff --git a/src/prompts/sdk/prepare.ts b/src/prompts/sdk/prepare.ts index 71cccf5e4..79a627b9f 100644 --- a/src/prompts/sdk/prepare.ts +++ b/src/prompts/sdk/prepare.ts @@ -4,6 +4,7 @@ import { CommitId } from '../../types/sdk/commit-id.js'; import { describeGeneration, SdkGeneration } from '../../types/sdk/generation-record.js'; import { GitVersion } from '../../types/sdk/git-version.js'; import { SdkRepository } from '../../types/sdk/sdk-repository.js'; +import { SnapshotRef } from '../../types/sdk/snapshot-ref.js'; import { format as f } from '../format.js'; const plural = (count: number, noun: string) => `${count} ${noun}${count === 1 ? '' : 's'}`; @@ -51,10 +52,13 @@ export class SdkPreparePrompts { } public snapshotMissing(commit: CommitId, repository: SdkRepository) { + const refs = `'${SnapshotRef.pristineNamespace()}/*:${SnapshotRef.pristineNamespace()}/*'`; const message = `The snapshot this SDK was generated from, commit ${f.var(commit.toString())}, is not in the repository at ` + - `${f.relativePath(repository.root())}, so your customizations cannot be merged. ` + - `The SDK will be replaced and a fresh snapshot recorded.`; + `${f.relativePath(repository.root())}, so your customizations cannot be merged.\n` + + `Whoever generated it can share it with ${f.cmd('git', 'push', 'origin', refs)}\n` + + `and you can fetch it with ${f.cmd('git', 'fetch', 'origin', `'+${refs.slice(1)}`)}, then rerun.\n` + + `Otherwise the SDK will be replaced and a fresh snapshot recorded.`; log.warn(message); } diff --git a/src/prompts/sdk/write.ts b/src/prompts/sdk/write.ts index cd9a97e5d..a834112ef 100644 --- a/src/prompts/sdk/write.ts +++ b/src/prompts/sdk/write.ts @@ -54,6 +54,13 @@ export class SdkWritePrompts { log.warn(message); } + public conflictsNotStaged(reason: string) { + const message = + `The conflicted files could not be marked in git's index: ${reason}\n` + + `The markers in the files are still there; only ${f.cmd('git', 'status')} will not list them as both modified.`; + log.warn(message); + } + public sdkMerged(sdk: DirectoryPath, plan: MergePlan) { const counts = (Object.keys(COUNT_LABELS) as (keyof MergeCounts)[]) .filter((key) => key !== 'unchanged' && plan.counts[key] > 0) @@ -67,7 +74,8 @@ export class SdkWritePrompts { (conflict) => `${f.var(conflict.path)}\n ${conflict.message ?? CONFLICT_LABELS[conflict.kind]}` ); const steps = [ - 'Open each file, keep what is right, and remove the markers.', + `Open each file, keep what is right, and remove the markers; ${f.cmd('git', 'status')} lists them as ` + + `both modified and ${f.cmd('git', 'mergetool')} opens them.`, 'Rerun the command; nothing needs committing first.', `The directory as it was before this run is under ${f.var(repository.previousRef().toString())}.` ]; diff --git a/src/types/events/sdk-merge-conflicted.ts b/src/types/events/sdk-merge-conflicted.ts new file mode 100644 index 000000000..55927af46 --- /dev/null +++ b/src/types/events/sdk-merge-conflicted.ts @@ -0,0 +1,13 @@ +import { DomainEvent } from './domain-event.js'; + +/** A regeneration that left conflicts: how many of each kind, never which files. */ +export class SdkMergeConflictedEvent extends DomainEvent { + protected readonly eventName = SdkMergeConflictedEvent.name; + private readonly conflicts: Readonly>; + + constructor(conflicts: Readonly>, commandName: string, flags: Record) { + const total = Object.values(conflicts).reduce((sum, count) => sum + count, 0); + super(`Regeneration left ${total} conflicted file${total === 1 ? '' : 's'}.`, commandName, flags); + this.conflicts = conflicts; + } +} diff --git a/src/types/sdk/sdk-repository.ts b/src/types/sdk/sdk-repository.ts index 7ae7ac891..f0f50feab 100644 --- a/src/types/sdk/sdk-repository.ts +++ b/src/types/sdk/sdk-repository.ts @@ -37,6 +37,12 @@ export class SdkRepository { return SnapshotRef.previous(this.sdkPath()); } + /** A path below the SDK directory, with `/` between segments, as git names it from the repository root. */ + public rootRelative(relative: string): string { + const sdkPath = this.sdkPath(); + return sdkPath === '' ? relative : `${sdkPath}/${relative}`; + } + private sdkPath(): string { return posix(path.relative(this.rootDirectory.toString(), this.sdk.toString())); } diff --git a/test/actions/sdk/prepare.test.ts b/test/actions/sdk/prepare.test.ts index 3c3a576a2..8ec5878ad 100644 --- a/test/actions/sdk/prepare.test.ts +++ b/test/actions/sdk/prepare.test.ts @@ -2,6 +2,7 @@ import fs from 'fs'; import os from 'os'; import path from 'path'; import { expect } from 'chai'; +import { execa } from 'execa'; import { err } from 'neverthrow'; import sinon from 'sinon'; import { SdkPrepareAction } from '../../../src/actions/sdk/prepare.js'; @@ -120,6 +121,38 @@ describe('SdkPrepareAction', () => { expect(snapshotMissing.firstCall.args[0].isEqual(missing)).to.be.true; }); + it('merges in a clone only once the pristine refs were fetched by hand, and says how', async () => { + const remote = fs.mkdtempSync(path.join(os.tmpdir(), 'prepare-sdk-remote-')); + const clone = fs.mkdtempSync(path.join(os.tmpdir(), 'prepare-sdk-clone-')); + const sh = (cwd: string, ...args: string[]) => + execa('git', ['-c', 'user.name=t', '-c', 'user.email=t@t', ...args], { cwd }); + try { + await recorded({ 'README.md': '# sdk' }); + const base = (await store.readRecord(sdk()))!.pristineCommit(); + await sh(root, 'add', '.'); + await sh(root, 'commit', '-qm', 'sdk'); + await sh(root, 'init', '-q', '--bare', remote); + await sh(root, 'remote', 'add', 'origin', remote); + await sh(root, 'push', '-q', 'origin', 'main'); + await sh(root, 'clone', '-q', remote, clone); + sdkContext = new SdkContext(Language.PYTHON, new DirectoryPath(path.join(clone, 'sdk'))); + const snapshotMissing = sinon.stub(SdkPreparePrompts.prototype, 'snapshotMissing'); + + expect((await execute()).getValue().kind).to.equal('record'); + expect(snapshotMissing.firstCall.args[0].isEqual(base)).to.be.true; + + await sh(root, 'push', '-q', 'origin', 'refs/apimatic/pristine/*:refs/apimatic/pristine/*'); + await sh(clone, 'fetch', '-q', 'origin', '+refs/apimatic/pristine/*:refs/apimatic/pristine/*'); + + const destination = (await execute()).getValue(); + expect(destination.kind).to.equal('merge'); + expect(destination.kind === 'merge' && destination.base.isEqual(base)).to.be.true; + } finally { + fs.rmSync(remote, { recursive: true, force: true }); + fs.rmSync(clone, { recursive: true, force: true }); + } + }); + it('merges against the recorded snapshot, without asking, when the record and its commit are there', async () => { const repository = await recorded({ 'README.md': '# sdk' }); write('README.md', '# edited'); diff --git a/test/actions/sdk/write.test.ts b/test/actions/sdk/write.test.ts index b308d300f..70cd8d4e9 100644 --- a/test/actions/sdk/write.test.ts +++ b/test/actions/sdk/write.test.ts @@ -3,11 +3,13 @@ import os from 'os'; import path from 'path'; import { TextDecoder } from 'node:util'; import { expect } from 'chai'; +import { execa } from 'execa'; import { err } from 'neverthrow'; import sinon from 'sinon'; import { SdkWriteAction } from '../../../src/actions/sdk/write.js'; import { GENERATED_MARKER, YOURS_MARKER } from '../../../src/application/sdk-merge/markers.js'; import { GitService } from '../../../src/infrastructure/git-service.js'; +import { MergeService } from '../../../src/infrastructure/merge-service.js'; import { PristineStoreService } from '../../../src/infrastructure/pristine-store-service.js'; import { ZipService } from '../../../src/infrastructure/zip-service.js'; import { SdkWritePrompts } from '../../../src/prompts/sdk/write.js'; @@ -208,6 +210,29 @@ describe('SdkWriteAction', () => { expect(read('models/book.py')).to.contain(`${GENERATED_MARKER}\n`); expect(read('README.md')).to.equal('# sdk v2'); expect(record().conflictedPaths()).to.deep.equal(['models/book.py']); + expect(result.getError()?.conflictsByKind()).to.include({ bothChanged: 1, noLongerGenerated: 0 }); + const cwd = repository.root().toString(); + expect((await execa('git', ['status', '--porcelain'], { cwd })).stdout).to.contain( + 'UU sdk/python/models/book.py' + ); + const staged = (await execa('git', ['ls-files', '--stage', '--', 'sdk/python/models/book.py'], { cwd })).stdout; + expect(staged.split('\n').map((line) => line.split(/\s+/)[2])).to.deep.equal(['1', '2', '3']); + }); + + it('still reports the conflicts when they cannot be marked in the index, with a warning', async () => { + const { repository, base } = await recorded({ 'README.md': '# sdk', 'models/book.py': MODEL_V1 }); + write(sdk(), { 'models/book.py': MODEL_V1_EDITED }); + sinon.stub(MergeService.prototype, 'stageConflicts').resolves(err({ kind: 'gitFailed', reason: 'index locked' })); + const conflictsNotStaged = sinon.stub(SdkWritePrompts.prototype, 'conflictsNotStaged'); + const conflicts = sinon.stub(SdkWritePrompts.prototype, 'conflicts'); + + const result = await execute({ kind: 'merge', repository, base }, { 'models/book.py': MODEL_V2 }); + + expect(result.isFailed()).to.be.true; + expect(result.getMessage()).to.equal('Resolve the conflicts and rerun'); + expect(conflictsNotStaged.firstCall.args[0]).to.equal('index locked'); + expect(conflicts.called).to.be.true; + expect(read('models/book.py')).to.contain(YOURS_MARKER); }); it('leaves the directory alone when the pre-run state cannot be kept', async () => { diff --git a/test/types/events/sdk-merge-conflicted.test.ts b/test/types/events/sdk-merge-conflicted.test.ts new file mode 100644 index 000000000..86489a10c --- /dev/null +++ b/test/types/events/sdk-merge-conflicted.test.ts @@ -0,0 +1,25 @@ +import { expect } from 'chai'; +import { SdkMergeConflictedEvent } from '../../../src/types/events/sdk-merge-conflicted.js'; + +describe('SdkMergeConflictedEvent', () => { + it('carries the counts by kind and the flag names, and no file', () => { + const event = new SdkMergeConflictedEvent({ bothChanged: 2, noLongerGenerated: 1 }, 'sdk generate', { + language: 'python', + force: true + }); + + expect(JSON.parse(JSON.stringify(event))).to.deep.equal({ + eventName: 'SdkMergeConflictedEvent', + message: 'Regeneration left 3 conflicted files.', + commandName: 'sdk generate', + flags: ['language', 'force'], + conflicts: { bothChanged: 2, noLongerGenerated: 1 } + }); + }); + + it('counts one file in the singular', () => { + const event = new SdkMergeConflictedEvent({ binary: 1 }, 'sdk generate', {}); + + expect(JSON.parse(JSON.stringify(event)).message).to.equal('Regeneration left 1 conflicted file.'); + }); +}); From d857ea4502f0506d935d95d299f48a2c3e7a92f1 Mon Sep 17 00:00:00 2001 From: Asad Ali <14asadali@gmail.com> Date: Tue, 20 Oct 2026 15:04:53 +0500 Subject: [PATCH 05/29] docs(sdk): record the custom-code design and how edits are kept The plan behind keeping edits across regenerations, in `.ai/plans/`, the four terms it introduces in `CONTEXT.md`, a README section and the git requirement, and one more sentence in the command's description. Co-Authored-By: Claude Fable 5.1 --- .ai/plans/custom-code.md | 339 +++++++++++++++++++++++++++++++++++ CONTEXT.md | 18 ++ README.md | 33 ++++ src/commands/sdk/generate.ts | 4 +- 4 files changed, 393 insertions(+), 1 deletion(-) create mode 100644 .ai/plans/custom-code.md diff --git a/.ai/plans/custom-code.md b/.ai/plans/custom-code.md new file mode 100644 index 000000000..8ea2c033c --- /dev/null +++ b/.ai/plans/custom-code.md @@ -0,0 +1,339 @@ +# Plan: keep your edits when regenerating an SDK + +Status: implemented on branch `asadali214/custom-code-injection`, cut from `dev` at +`4baa9485`, as five commits reviewed phase by phase on 2026-10-20: `db3fe9f2` (phase 1, +first built on isomorphic-git), `185e0663` (phases 1 and 2 reworked onto the system git), +`4d5b9ccf` (phase 3), `87453917` (phases 4 and 5), and this file with its README and +`CONTEXT.md` entries (phase 6). Section 10 lists what still needs a decision before the PR +into `dev`. The design was tested against Speakeasy's generator first; section 7 records +what that lab measured and what this plan does on the same cases. + +## 1. Goal and scope + +A developer edits a generated SDK, any file and any line, and regenerates it with the same +command; their edits survive, the generator's changes arrive, and the two are merged file by +file with git's three-way merge. Nothing to configure, no markers or regions to stay inside, +no commit required before regenerating. + +What changes for the developer: + +- **Git is required.** The CLI uses the git on `PATH`, 2.38 or newer, for the snapshots and + the merge. Without it `sdk generate` behaves exactly as before, writes no `.git` and no + `.apimatic/`, and warns: "git was not found, so your customizations to the generated SDK + will not persist across regenerations". +- **First run**: as before, a spec and a language; the SDK lands in `/sdk/`. + If no git repository encloses that directory, the CLI runs `git init` in it and says so. +- **Regenerate in place**: the same command. Generator changes are merged into edited files, + new files are added, files the generator stopped producing are removed when unedited. +- **Conflicts are explicit**: overlapping changes get git-style markers, the run exits 1 and + lists the files. Resolve, rerun. +- **Teams**: the merge base is local to the machine that generated. The CLI never pushes or + fetches; a team that wants a clone to merge pushes `refs/apimatic/pristine/*` itself (3.2). +- **No base, no merge**: an SDK directory with no generation record, or whose recorded + snapshot is missing, gets the "overwrite?" question as before; the run then records a + snapshot so the next one merges. + +Terms, also in `CONTEXT.md`: the **SDK directory** is the `` directory the CLI +writes into, input and output of every later run; the **repository** is the nearest git +repository enclosing it; a **pristine snapshot** is one generation's output exactly as the +server returned it, stored as a commit under a hidden ref; the **generation record** is +`/.apimatic/generation.json`, naming the snapshot the directory was last written from; +**custom code** is whatever differs between the SDK directory on disk and its snapshot. + +Out of scope: an ignore list for files the developer wants frozen, patch files, code regions, +portal downloads that include custom code (section 10), and any sharing of snapshots by the +CLI. + +## 2. Decisions + +| Ask | Decision | Why | +|---|---|---| +| Where the merge runs | In the CLI, against the working tree. The server is unchanged. | Nothing is uploaded or downloaded beyond the SDK zip, the merge sees uncommitted edits, and the design matches Speakeasy's, which the lab showed working. | +| When it merges | Always, whenever git is available, the SDK directory sits in a git repository and has a generation record. No switch in `apimatic.json`. | One rule to explain: "with git installed, your edits are kept". A switch would be read only after the first edit is already lost. | +| Git is a hard requirement | The system git, 2.38 or newer, found on `PATH` at the start of every non-`--zip` run. Absent or older: today's flow, no `.git` or `.apimatic/` written, one warning. No bundled fallback engine. | Git's merge is the reference everyone else measures against: rename detection, modify/delete and add/add semantics, binary handling. Generators that embed a library write their own diff3 (Speakeasy, isomorphic-git); tools that accept git as a prerequisite shell out to it (JHipster, Copier, Cruft); none reimplements rename detection. 2.38 (October 2022) is the first with `merge-tree --write-tree`. | +| No repository | After the generation succeeds and before the first write, `git init -b main` in the SDK directory, reported afterwards. A non-empty directory without a repository is asked "overwrite?" first. | Without a snapshot of the first generation there is nothing to merge against later. The init is inside the developer's own destination directory. | +| Repository found but no record | Ask "overwrite?" as before (skipped by `--force`); then record a snapshot. | An SDK generated by an older CLI, copied in, or built from the portal has no base. | +| Record for another generation | A record for another stability (or generator) is "no base": a warning, the overwrite question, a fresh snapshot. | Merging a beta generation over a stable base would be a merge of everything; replacing is what the developer asked for. | +| Remote | Not used. The CLI never pushes or fetches; every snapshot stays local. | Pushing refs behind the developer's back is a surprise. Sharing is the team's choice (3.2). | +| Merge engine | `git merge-tree --write-tree` between two temporary commits (yours and the generation) that share the pristine snapshot as parent, run with the CLI's own `-c` settings. The CLI reads the result and writes it into the SDK directory. | No merge code to maintain; git supplies the three-way merge, rename detection and every conflict shape, without touching the working tree, the index or any branch. | +| Branches | None. Snapshots are commits under `refs/apimatic/…`, never checked out, never on a branch. | The CLI should not create or switch branches in someone's repository. The base for a run is the one the committed record names, so each branch carries its own. | +| Moves and renames | Git's rename detection, both ways, at its usual 50% similarity. | The lab's case 6 lost edits because Speakeasy relies on a header comment; git pairs by content. | +| Same path added by both | A conflict, unless the contents are identical. | Git semantics; Speakeasy overwrites the developer's file silently. | +| Generator drops an edited file | A conflict: the file stays, listed as "no longer generated". | Lab case 7: Speakeasy deletes it with no warning. | +| Developer deleted a file the generator changed | A conflict: the new version is written back and listed. Needs sign-off, section 10. | Git semantics. The alternative, remembering the deletion, is silent and recurs on docs files. | +| `--force` | Keeps its meaning: never ask. With a base it changes nothing, since nothing is asked. | CI runs already pass it. | +| `--zip` | Unchanged: no repository, no record, no merge. | A zip is not a working tree. | +| Undo | The SDK directory as it was before the run is kept under `refs/apimatic/previous/…`, overwritten each run. | A merge that goes wrong on uncommitted edits would otherwise be unrecoverable. | +| Conflicts in git's index | The conflicted paths are put into the repository's index at stages 1, 2 and 3. | `git status` says "both modified" and `git mergetool` opens them, as after a merge git ran itself. | + +## 3. What is saved, and where + +Two hidden refs per SDK directory plus one small file inside it. `` below is the SDK +directory relative to the repository root with forward slashes (`sdk/typescript`, or +`sdk/2.1.0/typescript` with `--api-version`); when the SDK directory is the repository root +the ref has no suffix. + +| Item | Holds | Written | Shared | +|---|---|---|---| +| `refs/apimatic/pristine/` | A chain of commits, one per generation. Each tree is that generation's output exactly as unzipped: no `.git`, no `.apimatic/`, no developer files. | After every successful write, with plumbing: the generated directory is added to a temporary index (`GIT_INDEX_FILE`, `--work-tree`), then `write-tree`, `commit-tree` and `update-ref`. Author "APIMatic CLI ", message `apimatic: pristine typescript v4 stable`. | Not by the CLI. A team may push it (3.2). | +| `refs/apimatic/previous/` | One commit, no parent: the SDK directory as it was on disk the moment before the last run wrote to it (`.git` excluded). | Before the merge writes anything. Overwritten each run. | No. Local safety net: `git checkout refs/apimatic/previous/sdk/typescript -- sdk/typescript` restores it. | +| `/.apimatic/generation.json` | `schemaVersion`, `language`, `codegenVersion`, `stability`, `pristine` (the commit the directory was written from), `generatedAt`, `cliVersion`, and `conflicts` (paths left with markers, empty on a clean run). | After the pristine commit exists, so the two never disagree. | Yes: an ordinary tracked file, committed with the SDK. It is what makes a branch, a worktree or a clone merge against the right base. | + +### 3.1 Which snapshot is the base + +The commit named in the generation record on disk, never the ref's tip. The tip only says +where the chain continues. If the named commit is absent locally, the CLI says which commit +is missing, how a teammate can push it and the reader fetch it (3.2), and offers to overwrite +instead, which records a fresh snapshot. + +### 3.2 Sharing is manual + +The CLI never contacts a remote. A new pristine commit's parent is the local tip, so each +machine keeps its own chain. A team that wants clones and CI to merge rather than overwrite +shares the refs itself: + +```sh +git push origin 'refs/apimatic/pristine/*:refs/apimatic/pristine/*' # after generating +git fetch origin '+refs/apimatic/pristine/*:refs/apimatic/pristine/*' # before regenerating elsewhere +``` + +The record names the exact commit, so a fetched ref that does not contain it is reported +the same way as a missing one. + +### 3.3 Rules + +- The ref name is the path, so an SDK directory nested inside another SDK directory is + refused before anything is uploaded (the refs would collide). +- `.git/` is never part of a snapshot. `.apimatic/` is in a previous snapshot (so an undo + restores the record too) but never in a pristine one, and the merge leaves it alone since + only the disk side has it. +- A snapshot of the disk honours the SDK's own `.gitignore` (so a `node_modules` or `dist` + never enters a tree), except that every path the pristine snapshot holds is added + regardless, so a generated file an ignore rule happens to match is still seen and never + mistaken for one the developer deleted. +- The developer's git configuration is bypassed: every git call runs with + `-c core.autocrlf=false`, `-c merge.conflictStyle=merge`, `-c merge.renormalize=true`, + raised rename limits, `gc.auto=0`, a temporary attributes file holding `* text=auto`, and + `LC_ALL=C`, so two machines produce the same trees and the same markers and git's messages + stay parseable. +- Snapshots are ordinary git objects: deduplicated, packed by the developer's own `git gc`, + invisible to `git log` and to a plain `git push`. +- `.apimatic/` never ships inside a published package: the three v4 generators' manifests are + whitelists that do not name it (verified 2026-10-20, section 10). + +## 4. One run of `apimatic sdk generate` + +### 4.1 Before this plan + +The destination was checked before anything was uploaded, and a non-empty one was emptied +before the fresh SDK was copied in. Nothing on disk survived a regeneration. + +### 4.2 With this plan + +```mermaid +flowchart TD + A([apimatic sdk generate -l typescript]) --> B{--zip?} + B -- yes --> Z([Today's flow: download the zip; done]) + B -- no --> V{git 2.38+ on PATH?} + V -- no --> V1([Warn: customizations will not persist; today's flow, no .git, no .apimatic]) + V -- yes --> D{Generation record and its pristine commit found?} + D -- no, directory empty --> C1 + D -- no, directory not empty --> F{Overwrite? / --force} + F -- no --> X([Cancelled, nothing touched]) + F -- yes --> C1[Zip src/ and upload; poll; download the SDK zip into a temp directory] + C1 --> R1[git init in the SDK directory if no repository encloses it] + R1 --> E[Write the SDK; an overwrite empties the directory but keeps any existing .git] + D -- yes --> G{Markers left from the last run?} + G -- yes --> Y([Failed: resolve those files first]) + G -- no --> C2[Zip src/ and upload; poll; download the SDK zip into a temp directory] + C2 --> H[Save disk state to refs/apimatic/previous] + H --> I[git merge-tree of yours and the generation over the pristine base; write the result in place] + E --> J[Record pristine commit; write generation.json] + I --> J + J --> K{Conflicts?} + K -- no --> S([Success: summary of files]) + K -- yes --> T([Failed: list of conflicts and what to do]) +``` + +The run is three actions. `GenerateAction` validates as before and generates into a temp +directory. `SdkPrepareAction` runs before the upload and returns an `SdkDestination`: + +1. `--zip` takes today's path entirely: `overwrite`. +2. `git --version`. Absent, or older than 2.38: warn that customizations will not persist; + `overwrite`, writing no `.git` and no `.apimatic/`. +3. `PristineStoreService.inspect` gathers the facts: the enclosing repository (from the + nearest existing ancestor, since a first run's directory is not there yet), the nesting + check, the record, whether its commit is present, and which listed files still hold + markers. A nested SDK directory is refused here. +4. No repository, no record, a record for another generation, or a missing commit: `record`, + each with its message. Markers left: refused. Otherwise `merge` against the recorded base. +5. The overwrite question is asked for `overwrite` and `record` when the directory holds + files, never for `merge`, and never with `--force`. + +After the download, `SdkWriteAction` writes it the way the destination says: + +- `overwrite`: today's save, as a directory or a zip; a `.git` in the directory stays. +- `record`: `git init -b main` in the SDK directory when nothing encloses it (after the + generation succeeded, so a failed one leaves no repository behind), the save, then the + pristine commit and the record. A failure to record is a warning: the SDK is on disk, and + the next run asks before replacing it. +- `merge`: the generated tree, the previous ref, the disk tree, `merge-tree`, the writes and + deletions (only paths whose content differs between the disk and the merged tree; the + directory is never emptied), the pristine commit and the record, the summary. With + conflicts: the stages into the index, the conflict block, and a failed result carrying the + plan, which the command turns into a telemetry event of counts by kind. + +## 5. The merge, file by file + +Three trees: base (the pristine snapshot), yours (the SDK directory on disk, snapshotted as +in 3.3) and new (the fresh generation). Yours and new are written as temporary commits whose +parent is the base, so `git merge-tree --write-tree yours new` finds the base itself and +returns a merged tree, the conflicted paths with their stages, and one informational line +per conflict naming its kind. The CLI does none of the deciding: + +| Base | Yours | New | Action | +|---|---|---|---| +| absent | absent | present | Write it (new generated file). | +| absent | present | present | Identical: nothing. Different: conflict add/add, markers around both versions. | +| present | = base | present | Write the new version (you never edited it). | +| present | edited | = base | Keep yours (the generator did not change it). | +| present | edited | = yours | Nothing. | +| present | edited | changed | Three-way line merge. Non-overlapping: write the merged file. Overlapping: conflict markers. | +| present | absent | = base | Stays deleted. | +| present | absent | changed | Conflict "deleted by you, changed by the generator": the new version is written back and listed. Section 10. | +| present | = base | absent | Delete it (the generator no longer produces it). | +| present | edited | absent | Conflict "no longer generated": your file stays and is listed. | +| present | absent | absent | Nothing. | +| absent | present | absent | Your own file: never touched. | + +Renames: git's detection on both sides at its default 50% similarity, with `merge.renameLimit` +raised. The generator renamed a file: your edits are merged into it at the new name and the +old name is removed. You moved a file: the generator's change to the old name is merged into +your file where it is now. A file both sides renamed differently is a rename conflict git +reports; the CLI lists it with git's message, and both candidates stay on disk. + +Content: trees are stored with `* text=auto` and merged with `merge.renormalize`, so a CRLF +file and its LF counterpart compare as equal and merge line by line; a file the merge changes +is written with LF, a file it leaves alone keeps its ending. Binary files are git's binary +files: unedited ones are replaced, edited ones are kept, and an edited one the generator also +changed is a conflict that keeps yours. + +## 6. Conflicts + +The merge finishes for every file; a conflict in one file does not stop the others. A line +conflict is written into the file, with git's labels (the temporary commit ids) replaced as +the file is written: + +``` +<<<<<<< Your changes + * Price in USD, VAT included. +======= + * Retail price in US dollars. +>>>>>>> Generated by APIMatic +``` + +The other kinds carry no markers: a file no longer generated stays as yours; a file you +deleted comes back in its new version; a binary keeps yours. All are listed by kind in a +block after the summary, "N files need your attention", with the steps: open each file, keep +what is right, remove the markers (`git status` lists them as both modified, `git mergetool` +opens them); rerun, nothing needs committing first; the pre-run state is under +`refs/apimatic/previous/…`. + +The result is failed, so the exit code is 1 and scripts and CI notice; the outro says the +conflicts need resolving, not that generation failed. Nothing is lost: every other file is +already updated, the pristine snapshot is recorded, the record lists the conflicted paths. + +The next run: while any listed file still holds the marker lines, the run stops before +generating and names them. Once resolved, no commit is needed. With the spec unchanged the +rerun changes nothing, because new equals base. With a changed spec it merges onto your +resolution. Git's index keeps the three stages until `git add` resolves them, as after any +merge. + +CI: with `--force` nothing is asked; conflicts fail the job with the same list. Opening pull +requests is not the CLI's job; an Action that commits the marked files to a branch for +review is a separate piece of work. + +## 7. Expected behaviour on the lab's cases + +The eleven Speakeasy runs from the lab notebook, plus three the lab did not cover. + +| # | Case | Speakeasy (measured) | This plan | +|---|---|---|---| +| 1 | Edit with nothing configured | lost unless the prompt is answered Yes | kept; there is nothing to configure | +| 2 | Edits and spec changes in different places | kept | kept | +| 3 | Same line changed by both | conflict markers, exit 1 | conflict markers, exit 1, pre-run state kept, stages in the index | +| 4 | Developer adds new files | kept | kept | +| 5 | Developer deletes a generated file | stays deleted | stays deleted while the generator leaves it alone; conflict once the generator changes it (section 10) | +| 6 | Developer moves a generated file | lost: rewritten from scratch | kept: matched by content, merged at the new path | +| 7 | Generator drops an edited file | lost: deleted silently | conflict: file kept and listed | +| 8 | `.genignore` frozen file | drifts from the spec | not offered; an ignore list is a possible follow-up | +| 9 | Patch files | replayed every run | not offered; edits in place cover the need | +| 10 | Code regions | kept inside markers, lost outside | not offered; every line is editable | +| 11 | SDK hooks registration file | written once | unchanged: hooks are generator features, merged like any file | +| 12 | Both add the same path with different content | developer's file overwritten | conflict add/add | +| 13 | Generator renames an edited file | lost | kept: edits follow the rename | +| 14 | Fresh clone, regenerate | fails unless the hidden refs were pushed | merges if the team pushed and fetched the refs; otherwise a clear message with the commands and the overwrite question | + +Every row of section 5 and this table is a test over real repositories in +`test/infrastructure/merge-service.test.ts`, `test/actions/sdk/prepare.test.ts` (row 14 with +a bare remote) and `test/actions/sdk/write.test.ts`. + +## 8. Phases, as delivered + +| Phase | Commit | What | +|---|---|---| +| 1 | `db3fe9f2`, reworked in `185e0663` | `GitService` (the system git through `execa`, every call with the 3.3 settings) and `PristineStoreService` with the value objects: repository, refs, commit and tree ids, git version, generation record. | +| 2 | `185e0663` | `MergeService`: the temporary commits, `merge-tree --write-tree -z`, the diffs; `src/application/sdk-merge/` keeps only what is pure: parsing merge-tree's output, the conflict kinds, marker relabelling, `MergePlan`. | +| 3 | `4d5b9ccf` | `SdkPrepareAction`, `SdkDestination`, `SdkWriteAction`, their prompts, `PristineStoreService.inspect`; `GenerateAction` back to validation and generation; `SdkContext.save` keeps `.git`. | +| 4, 5 | `87453917` | The missing-snapshot message with the push and fetch commands; `stageConflicts` through `update-index --index-info`; `SdkMergeConflictedEvent`. | +| 6 | this file | `CONTEXT.md` terms, the README section and requirement, the command description. | + +PR into `dev`, squash-merged, title `feat(sdk): keep your edits when regenerating an SDK`. + +## 9. Files + +| File | Role | +|---|---| +| `src/infrastructure/git-service.ts` | the system git behind every snapshot, the merge and the index staging | +| `src/infrastructure/pristine-store-service.ts` | snapshots, the record, `inspect` | +| `src/infrastructure/merge-service.ts` | the merge, its application to disk, the staging | +| `src/application/sdk-merge/*.ts` | pure: merge-tree output, conflict kinds, markers, `MergePlan` | +| `src/types/sdk/{sdk-repository,snapshot-ref,commit-id,tree-id,git-version,generation-record,sdk-destination,snapshot-files}.ts` | value objects | +| `src/actions/sdk/{generate,prepare,write}.ts`, `src/prompts/sdk/{generate,prepare,write}.ts` | the run | +| `src/types/sdk-context.ts` | `directory()`, save keeps `.git` | +| `src/types/events/sdk-merge-conflicted.ts`, `src/commands/sdk/generate.ts` | the conflict event | +| `test/…` | mirrors each file above | + +## 10. Needs a decision + +- **Deleted by you, changed by the generator.** Conflict (this plan) or remembered deletion + as Speakeasy does. The conflict recurs on docs files that change with every version bump; + the memory is silent. +- **`git init` without asking.** This plan tells the developer afterwards. The alternative is + a question on the first run, which CI would need `--force` to pass. +- **`SdkContext.directory()`.** The context skill marks a public directory accessor as the + pattern to avoid; this one falls under its exception (a layout context answering with a + derived directory for a prompt that names it and a service that works on it). The + alternative is a method handing back the store's findings, so no caller holds the path. +- **The marker check** covers the files the record lists, not every tracked file. Reading + the whole SDK before every run seemed a high price for a case the record already covers. +- **Packages must exclude `.apimatic/`.** No change needed, checked 2026-10-20: all three v4 + manifests are whitelists (`package.json` `files`, the `.csproj`'s explicit `Pack="true"` + items, `[tool.poetry]` `packages`), and `npm pack`, `dotnet pack` and `python -m build` on + projects with a planted `.apimatic/generation.json` packed none of it. +- **Portal downloads.** `portal generate` ships the server's SDK zips, so a portal's download + never contains custom code. Zipping the merged SDK directory from disk is a follow-up. +- **No sharing by the CLI.** Decided: clones and CI overwrite unless the team pushes and + fetches `refs/apimatic/pristine/*` by hand. Revisit if that becomes the common complaint. + +## 11. Relation to other work + +The codegen-v2 server-side plan (custom code as a git merge the server performs, with the +`.git` zipped up and down each run) is superseded by this one; the server's `/api/generate` +needs none of it, and the beta CLI never uploads a source tree. The v3 CLI flow +(`--track-changes`, `save-changes`, `merge-source-tree`) was removed from `dev` in #427 with +its `git-service.ts` and the isomorphic-git dependency; the file name returns here for the +system-git wrapper, and no library is added. Speakeasy's `persistentEdits` has the same shape +(snapshots as hidden refs in the developer's repository, per-file three-way merge on the +working tree, markers plus a failed exit on conflict), with the four gaps the lab found +closed: moves, generator renames, add/add, and dropped edited files. diff --git a/CONTEXT.md b/CONTEXT.md index 09ad52aa5..597388a43 100644 --- a/CONTEXT.md +++ b/CONTEXT.md @@ -55,3 +55,21 @@ _Avoid_: Example name, variant **Unplaced sample**: A code sample whose endpoint no spec in the source directory declares. _Avoid_: Orphan sample, missing operation + +### SDK regeneration + +**SDK directory**: +The `` directory `sdk generate` writes an SDK into — `/sdk/`, or `/sdk//` — and reads back on every later run. +_Avoid_: Destination, output directory, language directory + +**Pristine snapshot**: +One generation's output exactly as the server returned it, stored as a commit under the hidden ref `refs/apimatic/pristine/` of the repository enclosing the SDK directory, never on a branch; the base of the next merge. +_Avoid_: Generated snapshot, baseline, original + +**Generation record**: +`/.apimatic/generation.json`, committed with the SDK, naming the pristine snapshot the directory was last written from and the files a run left with conflict markers. +_Avoid_: Lock file, manifest, metadata file + +**Custom code**: +Whatever differs between the SDK directory on disk and its pristine snapshot: the developer's edits, additions, deletions and moves, kept by merging the next generation over them. +_Avoid_: Customizations (in code), user changes, overrides diff --git a/README.md b/README.md index f04fca6e0..c0e48eabe 100644 --- a/README.md +++ b/README.md @@ -13,6 +13,10 @@ The official CLI for APIMatic. Node.js 24 or newer, for every command. npm only warns when your Node is older, so the install succeeds and the CLI then refuses to run, naming the version it found. +Git 2.38 or newer on your `PATH`, for `sdk generate` to keep your changes to a generated SDK +across regenerations (see [Keeping your changes to an SDK](#keeping-your-changes-to-an-sdk)). +Without it the command still generates, and warns that your customizations will not persist. + # Getting Started To get started with APIMatic's CLI using a step by step wizard, run the following command: @@ -21,6 +25,34 @@ To get started with APIMatic's CLI using a step by step wizard, run the followin $ apimatic quickstart ``` +# Keeping your changes to an SDK + +`apimatic sdk generate` writes the SDK into `/sdk/` (or `/sdk//`). +Edit anything in it: any file, any line, no markers or regions to stay inside and no configuration to turn on. +Run the same command again after changing your spec and the generator's changes are merged into your edited +files, new files are added, and files the generator stopped producing are removed when you had not edited them. + +This needs git 2.38 or newer. On the first run, if no git repository encloses the SDK directory, the CLI runs +`git init` in it and says so. Each run records the SDK exactly as generated as a commit under a hidden ref, +`refs/apimatic/pristine/`, and names that commit in `/.apimatic/generation.json`, which is committed +with the SDK. Nothing is pushed or fetched by the CLI, no branch is created, and no commit is needed before +regenerating. The SDK directory as it was before the last run is kept under `refs/apimatic/previous/`. + +When you and the generator changed the same lines, the file gets git-style conflict markers labelled +`Your changes` and `Generated by APIMatic`, the run lists the files and exits with code 1, and `git status` +shows them as both modified. Keep what is right, remove the markers, and rerun; until then the command refuses +to run. Every other file is already updated. + +A clone or a CI job has the record but not the hidden refs, so it asks before replacing the SDK (or replaces it +with `--force`) and records a fresh snapshot. A team that wants clones to merge shares the refs by hand: + +```sh-session +$ git push origin 'refs/apimatic/pristine/*:refs/apimatic/pristine/*' # after generating +$ git fetch origin '+refs/apimatic/pristine/*:refs/apimatic/pristine/*' # before regenerating elsewhere +``` + +`--zip` is unchanged: no repository, no record, no merge. + # Usage ```sh-session @@ -435,6 +467,7 @@ DESCRIPTION Generate a Software Development Kit (SDK) from an API specification. C#, TypeScript and Python are available; Java, Ruby, Go and PHP are coming soon. + With git 2.38 or newer installed, your edits to the generated SDK are kept when you regenerate it. EXAMPLES apimatic sdk generate --language=typescript diff --git a/src/commands/sdk/generate.ts b/src/commands/sdk/generate.ts index ecf7e89aa..a0920b3f3 100644 --- a/src/commands/sdk/generate.ts +++ b/src/commands/sdk/generate.ts @@ -10,6 +10,7 @@ import { Stability, UPCOMING_LANGUAGES } from '../../types/sdk/generate.js'; +import { GitVersion } from '../../types/sdk/git-version.js'; import { StabilityChoice } from '../../types/sdk/stability-choice.js'; import { CommandMetadata } from '../../types/common/command-metadata.js'; import { ProjectContext } from '../../types/project-context.js'; @@ -24,7 +25,8 @@ export default class SdkGenerate extends Command { static readonly summary = 'Generate an SDK for your API'; static readonly description = `Generate a Software Development Kit (SDK) from an API specification. -${inProse(AVAILABLE_LANGUAGES)} are available; ${inProse(UPCOMING_LANGUAGES)} are coming soon.`; +${inProse(AVAILABLE_LANGUAGES)} are available; ${inProse(UPCOMING_LANGUAGES)} are coming soon. +With git ${GitVersion.required()} or newer installed, your edits to the generated SDK are kept when you regenerate it.`; static readonly cmdTxt = format.cmd('apimatic', 'sdk', 'generate'); From fc026fbe79ec1dfc44427594eb1daddbe9484340 Mon Sep 17 00:00:00 2001 From: Asad Ali <14asadali@gmail.com> Date: Tue, 20 Oct 2026 16:16:00 +0500 Subject: [PATCH 06/29] docs(sdk): correct the undo command for the previous snapshot The previous ref's tree is rooted at the SDK directory, so the restore needs the work tree pointed there, as the lab verified. Co-Authored-By: Claude Fable 5.1 --- .ai/plans/custom-code.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.ai/plans/custom-code.md b/.ai/plans/custom-code.md index 8ea2c033c..8197bc115 100644 --- a/.ai/plans/custom-code.md +++ b/.ai/plans/custom-code.md @@ -76,7 +76,7 @@ the ref has no suffix. | Item | Holds | Written | Shared | |---|---|---|---| | `refs/apimatic/pristine/` | A chain of commits, one per generation. Each tree is that generation's output exactly as unzipped: no `.git`, no `.apimatic/`, no developer files. | After every successful write, with plumbing: the generated directory is added to a temporary index (`GIT_INDEX_FILE`, `--work-tree`), then `write-tree`, `commit-tree` and `update-ref`. Author "APIMatic CLI ", message `apimatic: pristine typescript v4 stable`. | Not by the CLI. A team may push it (3.2). | -| `refs/apimatic/previous/` | One commit, no parent: the SDK directory as it was on disk the moment before the last run wrote to it (`.git` excluded). | Before the merge writes anything. Overwritten each run. | No. Local safety net: `git checkout refs/apimatic/previous/sdk/typescript -- sdk/typescript` restores it. | +| `refs/apimatic/previous/` | One commit, no parent: the SDK directory as it was on disk the moment before the last run wrote to it (`.git` excluded). Its tree is rooted at the SDK directory, not at the repository root. | Before the merge writes anything. Overwritten each run. | No. Local safety net: `git --work-tree=sdk/typescript restore --source=refs/apimatic/previous/sdk/typescript --worktree -- .` puts the files back, record included (verified in the lab, case 15); files the run added stay and `git status` lists them. | | `/.apimatic/generation.json` | `schemaVersion`, `language`, `codegenVersion`, `stability`, `pristine` (the commit the directory was written from), `generatedAt`, `cliVersion`, and `conflicts` (paths left with markers, empty on a clean run). | After the pristine commit exists, so the two never disagree. | Yes: an ordinary tracked file, committed with the SDK. It is what makes a branch, a worktree or a clone merge against the right base. | ### 3.1 Which snapshot is the base From 6ef3540fca5ea3f989f1b9fd3b683d9af70844fd Mon Sep 17 00:00:00 2001 From: Asad Ali <14asadali@gmail.com> Date: Tue, 20 Oct 2026 17:38:24 +0500 Subject: [PATCH 07/29] feat(sdk): name the customized files after a regeneration The summary after a merge names the files that were merged with the developer's changes and the ones kept as edited, as paths relative to where the command ran, and counts the generator's own files and the conflicts; the conflict block lists paths the same way and its steps match the kinds present. A generation identical to the last keeps the last pristine commit instead of chaining a new one. Co-Authored-By: Claude Fable 5.1 --- src/actions/sdk/prepare.ts | 2 +- src/actions/sdk/write.ts | 2 +- src/application/sdk-merge/merge-plan.ts | 21 ++--- src/application/sdk-merge/sdk-merge.ts | 25 +++--- src/infrastructure/pristine-store-service.ts | 14 ++-- src/prompts/sdk/prepare.ts | 4 +- src/prompts/sdk/write.ts | 60 ++++++++++---- src/types/sdk/sdk-repository.ts | 7 ++ test/actions/sdk/write.test.ts | 7 +- test/application/sdk-merge/sdk-merge.test.ts | 8 +- test/infrastructure/merge-service.test.ts | 23 +++--- .../pristine-store-service.test.ts | 11 +++ test/prompts/sdk/write.test.ts | 78 +++++++++++++++++++ 13 files changed, 202 insertions(+), 60 deletions(-) create mode 100644 test/prompts/sdk/write.test.ts diff --git a/src/actions/sdk/prepare.ts b/src/actions/sdk/prepare.ts index 6a7f8ca8f..6f728c18d 100644 --- a/src/actions/sdk/prepare.ts +++ b/src/actions/sdk/prepare.ts @@ -73,7 +73,7 @@ export class SdkPrepareAction { this.prompts.snapshotMissing(state.value.commit, state.value.repository); return ActionResult.success({ kind: 'record', repository: state.value.repository }); case 'unresolved': - this.prompts.conflictsUnresolved(state.value.paths); + this.prompts.conflictsUnresolved(state.value.paths, state.value.repository); return ActionResult.failed(); case 'ready': return ActionResult.success({ diff --git a/src/actions/sdk/write.ts b/src/actions/sdk/write.ts index 127ae26cb..2e016d6c4 100644 --- a/src/actions/sdk/write.ts +++ b/src/actions/sdk/write.ts @@ -103,7 +103,7 @@ export class SdkWriteAction { return this.customCodeFailed(applied.error); } await this.record(repository, generated, generation, plan.conflictedPaths()); - this.prompts.sdkMerged(sdkContext.directory(), plan); + this.prompts.sdkMerged(repository, plan); if (!plan.hasConflicts()) { return ActionResult.success(); } diff --git a/src/application/sdk-merge/merge-plan.ts b/src/application/sdk-merge/merge-plan.ts index fdbffd80a..83b61d19a 100644 --- a/src/application/sdk-merge/merge-plan.ts +++ b/src/application/sdk-merge/merge-plan.ts @@ -19,19 +19,20 @@ export type Conflict = { path: string; kind: ConflictKind; message?: string }; export type Rename = { from: string; to: string; by: 'generator' | 'you' }; -export type MergeCounts = { +/** Every path the run touched or left alone, by what happened to it; conflicted paths are listed under `conflicts` instead. */ +export type MergeFiles = { /** Files the generator produced for the first time. */ - added: number; + added: readonly string[]; /** Unedited files replaced by their new version, or moved to the generator's new name. */ - updated: number; + updated: readonly string[]; /** Edited files the generator also changed, merged without conflict. */ - merged: number; + merged: readonly string[]; /** Unedited files the generator stopped producing. */ - removed: number; + removed: readonly string[]; /** Edited files the generator left alone. */ - kept: number; + kept: readonly string[]; /** Files neither side changed. */ - unchanged: number; + unchanged: readonly string[]; }; /** @@ -43,20 +44,20 @@ export class MergePlan { public readonly deletes: readonly string[]; public readonly conflicts: readonly Conflict[]; public readonly renames: readonly Rename[]; - public readonly counts: MergeCounts; + public readonly files: MergeFiles; constructor( writes: readonly FileWrite[], deletes: readonly string[], conflicts: readonly Conflict[], renames: readonly Rename[], - counts: MergeCounts + files: MergeFiles ) { this.writes = writes; this.deletes = deletes; this.conflicts = conflicts; this.renames = renames; - this.counts = counts; + this.files = files; } public hasConflicts(): boolean { diff --git a/src/application/sdk-merge/sdk-merge.ts b/src/application/sdk-merge/sdk-merge.ts index 73cc79b18..5bcbb7cce 100644 --- a/src/application/sdk-merge/sdk-merge.ts +++ b/src/application/sdk-merge/sdk-merge.ts @@ -1,4 +1,4 @@ -import { Conflict, ConflictKind, FileWrite, MergeCounts, MergePlan, Rename } from './merge-plan.js'; +import { Conflict, ConflictKind, FileWrite, MergeFiles, MergePlan, Rename } from './merge-plan.js'; import { MergeTreeOutput } from './merge-tree-output.js'; /** How a path changed between two trees, as `diff-tree` reports it. */ @@ -90,13 +90,20 @@ export function planMerge(inputs: MergeInputs): MergePlan { const writes: FileWrite[] = []; const deletes: string[] = []; - const counts: MergeCounts = { added: 0, updated: 0, merged: 0, removed: 0, kept: 0, unchanged: 0 }; + const files: Record = { + added: [], + updated: [], + merged: [], + removed: [], + kept: [], + unchanged: [] + }; for (const change of inputs.applied) { if (change.status === 'D') { deletes.push(change.path); if (!conflicted.has(change.path)) { - counts.removed += 1; + files.removed.push(change.path); } continue; } @@ -105,22 +112,22 @@ export function planMerge(inputs: MergeInputs): MergePlan { continue; } if (!base.has(change.path) && generated.get(change.path)?.status === 'A') { - counts.added += 1; + files.added.push(change.path); } else if (yours.get(change.path)?.status === 'M' && generated.has(change.path)) { - counts.merged += 1; + files.merged.push(change.path); } else { - counts.updated += 1; + files.updated.push(change.path); } } for (const [path, change] of yours) { if (change.status === 'M' && !generated.has(path) && !conflicted.has(path)) { - counts.kept += 1; + files.kept.push(path); } } for (const path of base) { if (!yours.has(path) && !generated.has(path)) { - counts.unchanged += 1; + files.unchanged.push(path); } } @@ -133,5 +140,5 @@ export function planMerge(inputs: MergeInputs): MergePlan { .map((change) => ({ from: change.from!, to: change.path, by: 'you' as const })) ]; - return new MergePlan(writes, deletes, conflicts, renames, counts); + return new MergePlan(writes, deletes, conflicts, renames, files); } diff --git a/src/infrastructure/pristine-store-service.ts b/src/infrastructure/pristine-store-service.ts index 2633fda60..e5db42b07 100644 --- a/src/infrastructure/pristine-store-service.ts +++ b/src/infrastructure/pristine-store-service.ts @@ -83,7 +83,7 @@ export class PristineStoreService { if (!(await this.git.hasCommit(repository.root(), base))) { return ok({ kind: 'snapshotMissing', repository, commit: base }); } - const paths = await this.unresolvedConflicts(sdkDirectory, record); + const paths = await this.unresolvedConflicts(repository, record); return ok(paths.length > 0 ? { kind: 'unresolved', repository, paths } : { kind: 'ready', repository, base }); } @@ -138,7 +138,7 @@ export class PristineStoreService { } } - /** Records `generated` as the next pristine snapshot of the SDK directory, chained on the last one. */ + /** Records `generated` as the next pristine snapshot of the SDK directory, chained on the last one; an identical generation keeps the last. */ public async recordPristine( repository: SdkRepository, generated: TreeId, @@ -151,6 +151,9 @@ export class PristineStoreService { const ref = repository.pristineRef(); try { const parent = await this.git.resolveRef(repository.root(), ref); + if (parent !== undefined && (await this.git.treeOfCommit(repository.root(), parent)).isEqual(generated)) { + return ok(parent); + } const commit = await this.git.commitTree( repository.root(), generated, @@ -253,13 +256,10 @@ export class PristineStoreService { } // The files the record lists as conflicted that still hold the markers, so a run never merges over them. - private async unresolvedConflicts(sdkDirectory: DirectoryPath, record: GenerationRecord): Promise { + private async unresolvedConflicts(repository: SdkRepository, record: GenerationRecord): Promise { const unresolved: string[] = []; for (const relative of record.conflictedPaths()) { - const file = new FilePath( - sdkDirectory.join(path.posix.dirname(relative)), - new FileName(path.posix.basename(relative)) - ); + const file = repository.file(relative); if ((await this.fileService.fileExists(file)) && hasConflictMarkers(await this.fileService.getContents(file))) { unresolved.push(relative); } diff --git a/src/prompts/sdk/prepare.ts b/src/prompts/sdk/prepare.ts index 79a627b9f..879920fca 100644 --- a/src/prompts/sdk/prepare.ts +++ b/src/prompts/sdk/prepare.ts @@ -62,12 +62,12 @@ export class SdkPreparePrompts { log.warn(message); } - public conflictsUnresolved(paths: readonly string[]) { + public conflictsUnresolved(paths: readonly string[], repository: SdkRepository) { const one = paths.length === 1; const message = `${plural(paths.length, 'file')} still hold${one ? 's' : ''} conflict markers from the last run. ` + `Resolve ${one ? 'it' : 'them'} and remove the markers, then rerun:\n` + - paths.map((path) => ` ${f.var(path)}`).join('\n'); + paths.map((path) => ` ${f.relativePath(repository.file(path))}`).join('\n'); log.error(message); } } diff --git a/src/prompts/sdk/write.ts b/src/prompts/sdk/write.ts index a834112ef..e72848001 100644 --- a/src/prompts/sdk/write.ts +++ b/src/prompts/sdk/write.ts @@ -1,5 +1,5 @@ import { log } from '@clack/prompts'; -import { Conflict, ConflictKind, MergeCounts, MergePlan } from '../../application/sdk-merge/merge-plan.js'; +import { Conflict, ConflictKind, MergeFiles, MergePlan } from '../../application/sdk-merge/merge-plan.js'; import { DirectoryPath } from '../../types/file/directoryPath.js'; import { SdkSaveProblem } from '../../types/sdk-context.js'; import { SdkRepository } from '../../types/sdk/sdk-repository.js'; @@ -16,17 +16,20 @@ const CONFLICT_LABELS: Record = { other: '' }; -const COUNT_LABELS: Record = { - added: 'added', - updated: 'updated', - merged: 'merged with your changes', - removed: 'removed', - kept: 'kept as you edited', - unchanged: 'unchanged' +const MARKED_KINDS: readonly ConflictKind[] = ['bothChanged', 'bothAdded']; + +/** The generator's own files are a count; the developer's are named. */ +const GENERATOR_FILES: readonly (keyof MergeFiles)[] = ['added', 'updated', 'removed']; +const YOUR_FILES: Readonly>> = { + merged: 'Merged with your changes:', + kept: 'Kept as you edited:' }; const plural = (count: number, noun: string) => `${count} ${noun}${count === 1 ? '' : 's'}`; +const listed = (repository: SdkRepository, paths: readonly string[]) => + paths.map((relative) => ` ${f.relativePath(repository.file(relative))}`); + export class SdkWritePrompts { public sdkNotSaved(problem: SdkSaveProblem) { log.error(`The SDK could not be saved to ${f.path(problem.sdkDirectory)}: ${problem.reason}`); @@ -61,21 +64,44 @@ export class SdkWritePrompts { log.warn(message); } - public sdkMerged(sdk: DirectoryPath, plan: MergePlan) { - const counts = (Object.keys(COUNT_LABELS) as (keyof MergeCounts)[]) - .filter((key) => key !== 'unchanged' && plan.counts[key] > 0) - .map((key) => `${plan.counts[key]} ${COUNT_LABELS[key]}`); - const summary = counts.length === 0 ? 'nothing changed' : listedInProse(counts); - log.info(`Regenerated the SDK at ${f.relativePath(sdk)} with your customizations: ${summary}.`); + public sdkMerged(repository: SdkRepository, plan: MergePlan) { + const generated = GENERATOR_FILES.filter((kind) => plan.files[kind].length > 0).map( + (kind) => `${plural(plan.files[kind].length, 'file')} ${kind}` + ); + if (plan.hasConflicts()) { + generated.push(`${plural(plan.conflicts.length, 'file')} in conflict`); + } + const summary = listedInProse(generated) || 'nothing changed'; + const lines = [`Regenerated the SDK at ${f.relativePath(repository.sdkDirectory())}: ${summary}.`]; + for (const [kind, heading] of Object.entries(YOUR_FILES) as [keyof MergeFiles, string][]) { + if (plan.files[kind].length > 0) { + lines.push(heading, ...listed(repository, plan.files[kind])); + } + } + log.info(lines.join('\n')); } public conflicts(conflicts: readonly Conflict[], repository: SdkRepository) { const lines = conflicts.map( - (conflict) => `${f.var(conflict.path)}\n ${conflict.message ?? CONFLICT_LABELS[conflict.kind]}` + (conflict) => + `${f.relativePath(repository.file(conflict.path))}\n ${conflict.message ?? CONFLICT_LABELS[conflict.kind]}` ); + const marked = conflicts.some((conflict) => MARKED_KINDS.includes(conflict.kind)); + const unmarked = conflicts.some((conflict) => !MARKED_KINDS.includes(conflict.kind)); const steps = [ - `Open each file, keep what is right, and remove the markers; ${f.cmd('git', 'status')} lists them as ` + - `both modified and ${f.cmd('git', 'mergetool')} opens them.`, + ...(marked + ? [ + `Open each marked file, keep what is right, and remove the markers; ${f.cmd('git', 'status')} lists ` + + `them as both modified and ${f.cmd('git', 'mergetool')} opens them.` + ] + : []), + ...(unmarked + ? [ + `For each ${ + marked ? 'other ' : '' + }file, decide whether it stays or goes: delete it again, or keep it as your own.` + ] + : []), 'Rerun the command; nothing needs committing first.', `The directory as it was before this run is under ${f.var(repository.previousRef().toString())}.` ]; diff --git a/src/types/sdk/sdk-repository.ts b/src/types/sdk/sdk-repository.ts index f0f50feab..8e545bf78 100644 --- a/src/types/sdk/sdk-repository.ts +++ b/src/types/sdk/sdk-repository.ts @@ -1,5 +1,7 @@ import path from 'node:path'; import { DirectoryPath, posix } from '../file/directoryPath.js'; +import { FileName } from '../file/fileName.js'; +import { FilePath } from '../file/filePath.js'; import { SnapshotRef } from './snapshot-ref.js'; /** An SDK directory together with the git repository that encloses it, which is where its snapshots live. */ @@ -37,6 +39,11 @@ export class SdkRepository { return SnapshotRef.previous(this.sdkPath()); } + /** The file at `relative` in the SDK directory, where `relative` has `/` between its segments as git and the record name it. */ + public file(relative: string): FilePath { + return new FilePath(this.sdk.join(path.posix.dirname(relative)), new FileName(path.posix.basename(relative))); + } + /** A path below the SDK directory, with `/` between segments, as git names it from the repository root. */ public rootRelative(relative: string): string { const sdkPath = this.sdkPath(); diff --git a/test/actions/sdk/write.test.ts b/test/actions/sdk/write.test.ts index 70cd8d4e9..7679c9b28 100644 --- a/test/actions/sdk/write.test.ts +++ b/test/actions/sdk/write.test.ts @@ -182,7 +182,12 @@ describe('SdkWriteAction', () => { expect(read('README.md')).to.equal('# sdk v2'); expect(read('models/author.py')).to.equal('class Author: ...'); expect(read('notes.txt')).to.equal('mine'); - expect(sdkMerged.firstCall.args[1].counts).to.include({ added: 1, updated: 1, merged: 1, removed: 0, kept: 0 }); + expect(sdkMerged.firstCall.args[1].files).to.deep.include({ + added: ['models/author.py'], + merged: ['models/book.py'], + removed: [], + kept: [] + }); expect(record().pristineCommit().isEqual(base)).to.be.false; expect(record().hasConflicts()).to.be.false; expect((await git.describe(repository.root(), record().pristineCommit())).parents).to.deep.equal([base]); diff --git a/test/application/sdk-merge/sdk-merge.test.ts b/test/application/sdk-merge/sdk-merge.test.ts index a885c2b90..a461a6add 100644 --- a/test/application/sdk-merge/sdk-merge.test.ts +++ b/test/application/sdk-merge/sdk-merge.test.ts @@ -1,3 +1,4 @@ +import { MergePlan } from '../../../src/application/sdk-merge/merge-plan.js'; import { expect } from 'chai'; import { MergeInputs, planMerge } from '../../../src/application/sdk-merge/sdk-merge.js'; import { MergeTreeOutput } from '../../../src/application/sdk-merge/merge-tree-output.js'; @@ -21,6 +22,9 @@ const inputs = (overrides: Partial): MergeInputs => ({ ...overrides }); +const counts = (plan: MergePlan) => + Object.fromEntries(Object.entries(plan.files).map(([kind, paths]) => [kind, paths.length])); + describe('planMerge', () => { it('turns the diff from the disk to the merged tree into writes and deletions, counted by who changed what', () => { const plan = planMerge( @@ -51,7 +55,7 @@ describe('planMerge', () => { { path: 'new.ts', blob: blob('ng') } ]); expect(plan.deletes).to.deep.equal(['d.ts']); - expect(plan.counts).to.deep.equal({ added: 1, updated: 1, merged: 1, removed: 1, kept: 1, unchanged: 1 }); + expect(counts(plan)).to.deep.equal({ added: 1, updated: 1, merged: 1, removed: 1, kept: 1, unchanged: 1 }); expect(plan.hasConflicts()).to.be.false; }); @@ -110,7 +114,7 @@ describe('planMerge', () => { ]); expect(plan.hasMarkers('same.ts')).to.be.true; expect(plan.hasMarkers('deleted-by-you.ts')).to.be.false; - expect(plan.counts).to.deep.equal({ added: 0, updated: 0, merged: 0, removed: 0, kept: 0, unchanged: 0 }); + expect(counts(plan)).to.deep.equal({ added: 0, updated: 0, merged: 0, removed: 0, kept: 0, unchanged: 0 }); }); it('reports the renames each side made', () => { diff --git a/test/infrastructure/merge-service.test.ts b/test/infrastructure/merge-service.test.ts index 262a6b8a0..803472524 100644 --- a/test/infrastructure/merge-service.test.ts +++ b/test/infrastructure/merge-service.test.ts @@ -58,6 +58,9 @@ type Run = { paths: () => string[]; }; +const counts = (plan: MergePlan) => + Object.fromEntries(Object.entries(plan.files).map(([kind, paths]) => [kind, paths.length])); + describe('MergeService', () => { let root: string; let counter = 0; @@ -133,7 +136,7 @@ describe('MergeService', () => { const { plan, read } = await run({ 'a.ts': 'v1\n' }, { 'a.ts': 'v1\n' }, { 'a.ts': 'v2\n' }); expect(read('a.ts')).to.equal('v2\n'); - expect(plan.counts.updated).to.equal(1); + expect(plan.files.updated).to.have.length(1); expect(plan.hasConflicts()).to.be.false; }); @@ -142,7 +145,7 @@ describe('MergeService', () => { expect(read('a.ts')).to.equal('v1 edited\n'); expect(plan.writes).to.be.empty; - expect(plan.counts.kept).to.equal(1); + expect(plan.files.kept).to.have.length(1); }); it('does nothing when neither side changed, or when you already made the generator’s change', async () => { @@ -150,7 +153,7 @@ describe('MergeService', () => { const ahead = await run({ 'a.ts': 'v1\n' }, { 'a.ts': 'v2\n' }, { 'a.ts': 'v2\n' }); expect(same.plan.writes).to.be.empty; - expect(same.plan.counts.unchanged).to.equal(1); + expect(same.plan.files.unchanged).to.have.length(1); expect(ahead.plan.writes).to.be.empty; expect(ahead.plan.hasConflicts()).to.be.false; }); @@ -163,7 +166,7 @@ describe('MergeService', () => { ); expect(read('book.ts')).to.equal(MODEL_V2 + HELPER); - expect(plan.counts.merged).to.equal(1); + expect(plan.files.merged).to.have.length(1); expect(plan.hasConflicts()).to.be.false; }); @@ -222,7 +225,7 @@ describe('MergeService', () => { expect(exists('authors.ts')).to.be.false; expect(plan.deletes).to.deep.equal(['authors.ts']); - expect(plan.counts.removed).to.equal(1); + expect(plan.files.removed).to.have.length(1); }); it('keeps a file the generator stopped producing when you edited it, as a conflict', async () => { @@ -247,7 +250,7 @@ describe('MergeService', () => { expect(read('delete-book.ts')).to.equal('new\n'); expect(read('custom/format.ts')).to.equal('mine\n'); - expect(plan.counts.added).to.equal(1); + expect(plan.files.added).to.have.length(1); expect(plan.writes.map((write) => write.path)).to.deep.equal(['delete-book.ts']); }); @@ -310,7 +313,7 @@ describe('MergeService', () => { expect(read('a.ts')).to.equal('zero\none\ntwo\nthree\n'); expect(read('b.ts')).to.equal('b\r\n'); - expect(plan.counts.merged).to.equal(1); + expect(plan.files.merged).to.have.length(1); expect(plan.hasConflicts()).to.be.false; }); @@ -322,7 +325,7 @@ describe('MergeService', () => { const both = await run({ 'logo.png': BINARY }, { 'logo.png': edited }, { 'logo.png': changed }); expect(replaced.bytes('logo.png')).to.deep.equal(changed); - expect(replaced.plan.counts.updated).to.equal(1); + expect(replaced.plan.files.updated).to.have.length(1); expect(both.bytes('logo.png')).to.deep.equal(edited); expect(both.plan.conflicts).to.deep.equal([{ path: 'logo.png', kind: 'binary' }]); }); @@ -361,7 +364,7 @@ describe('MergeService', () => { ]); expect(read('src/models/book.ts')).to.equal(MODEL_V2 + HELPER); expect(plan.conflicts).to.deep.equal([{ path: 'docs/models/book.md', kind: 'deletedByYou' }]); - expect(plan.counts).to.deep.equal({ added: 1, updated: 0, merged: 1, removed: 0, kept: 0, unchanged: 1 }); + expect(counts(plan)).to.deep.equal({ added: 1, updated: 0, merged: 1, removed: 0, kept: 0, unchanged: 1 }); }); it('keeps an edited file the v3 spec drops, and removes nothing else it did not produce', async () => { @@ -380,7 +383,7 @@ describe('MergeService', () => { expect(exists('src/sdk/authors.ts')).to.be.true; expect(read('src/models/book.ts')).to.equal(MODEL_V2); expect(plan.conflicts).to.deep.equal([{ path: 'src/sdk/authors.ts', kind: 'noLongerGenerated' }]); - expect(plan.counts.updated).to.equal(2); + expect(plan.files.updated).to.have.length(2); }); }); }); diff --git a/test/infrastructure/pristine-store-service.test.ts b/test/infrastructure/pristine-store-service.test.ts index 2936789e2..931ed3f33 100644 --- a/test/infrastructure/pristine-store-service.test.ts +++ b/test/infrastructure/pristine-store-service.test.ts @@ -148,6 +148,17 @@ describe('PristineStoreService', () => { expect(text((await service.readSnapshot(repository, first))._unsafeUnwrap().get('README.md'))).to.equal('# v1'); }); + it('keeps the last snapshot when the generation is identical, so the record names the same commit', async () => { + const repository = await project(); + const first = (await record(repository, { 'README.md': '# v1' }))._unsafeUnwrap(); + + const again = (await record(repository, { 'README.md': '# v1' }))._unsafeUnwrap(); + const changed = (await record(repository, { 'README.md': '# v2' }))._unsafeUnwrap(); + + expect(again.isEqual(first)).to.be.true; + expect((await git.describe(repository.root(), changed)).parents).to.deep.equal([first]); + }); + it('refuses an SDK directory inside one that already has snapshots, and the reverse', async () => { const outer = await project(); (await record(outer, { 'README.md': '# outer' }))._unsafeUnwrap(); diff --git a/test/prompts/sdk/write.test.ts b/test/prompts/sdk/write.test.ts new file mode 100644 index 000000000..f420522de --- /dev/null +++ b/test/prompts/sdk/write.test.ts @@ -0,0 +1,78 @@ +import path from 'node:path'; +import { stripVTControlCharacters } from 'node:util'; +import { expect } from 'chai'; +import sinon from 'sinon'; +import { log } from '@clack/prompts'; +import { MergePlan } from '../../../src/application/sdk-merge/merge-plan.js'; +import { SdkWritePrompts } from '../../../src/prompts/sdk/write.js'; +import { DirectoryPath } from '../../../src/types/file/directoryPath.js'; +import { SdkRepository } from '../../../src/types/sdk/sdk-repository.js'; + +const plan = (files: Partial, conflicts: MergePlan['conflicts'] = []) => + new MergePlan([], [], conflicts, [], { + added: [], + updated: [], + merged: [], + removed: [], + kept: [], + unchanged: [], + ...files + }); + +describe('SdkWritePrompts.sdkMerged', () => { + const root = new DirectoryPath(process.cwd()); + const sdk = SdkRepository.create(root, root.join('sdk', 'typescript'))!; + + afterEach(() => { + sinon.restore(); + }); + + it('counts the generator’s files and names yours, relative to where the command ran', () => { + const info = sinon.stub(log, 'info'); + + new SdkWritePrompts().sdkMerged( + sdk, + plan({ + added: ['src/models/author.ts'], + updated: ['README.md', 'src/client.ts'], + merged: ['src/models/book.ts'], + kept: ['src/resources/books.ts', 'docs/pricing.md'] + }) + ); + + expect(stripVTControlCharacters(info.firstCall.args[0])).to.equal( + [ + "Regenerated the SDK at './sdk/typescript': 1 file added and 2 files updated.", + 'Merged with your changes:', + " './sdk/typescript/src/models/book.ts'", + 'Kept as you edited:', + " './sdk/typescript/src/resources/books.ts'", + " './sdk/typescript/docs/pricing.md'" + ].join('\n') + ); + }); + + it('still names your files when the generator changed nothing, since they are what the next run will merge', () => { + const info = sinon.stub(log, 'info'); + + new SdkWritePrompts().sdkMerged(sdk, plan({ unchanged: ['README.md'], kept: ['src/models/book.ts'] })); + + expect(stripVTControlCharacters(info.firstCall.args[0])).to.equal( + [ + "Regenerated the SDK at './sdk/typescript': nothing changed.", + 'Kept as you edited:', + " './sdk/typescript/src/models/book.ts'" + ].join('\n') + ); + }); + + it('counts the conflicted files in the headline, so it never says nothing changed above a conflict', () => { + const info = sinon.stub(log, 'info'); + + new SdkWritePrompts().sdkMerged(sdk, plan({}, [{ path: 'src/resources/authors.ts', kind: 'noLongerGenerated' }])); + + expect(stripVTControlCharacters(info.firstCall.args[0])).to.equal( + "Regenerated the SDK at './sdk/typescript': 1 file in conflict." + ); + }); +}); From 971195b2ad220ff78f3867c51222a64d8e9519f5 Mon Sep 17 00:00:00 2001 From: Asad Ali <14asadali@gmail.com> Date: Tue, 20 Oct 2026 17:59:04 +0500 Subject: [PATCH 08/29] refactor(sdk): carry merge paths as RelativeFilePath A path inside a git tree, as `merge-tree`, `diff-tree` and the generation record name it, is now a value object: validated once where git's output and the record are parsed, turned into a file on disk through `in(directory)`, re-rooted for the index through `below`, and never a bare string in the merge plan, the services or the prompts. Co-Authored-By: Claude Fable 5.1 --- src/actions/sdk/write.ts | 6 +- src/application/sdk-merge/merge-plan.ts | 30 +++--- .../sdk-merge/merge-tree-output.ts | 9 +- src/application/sdk-merge/sdk-merge.ts | 62 ++++++++----- src/infrastructure/git-service.ts | 27 +++--- src/infrastructure/merge-service.ts | 6 +- src/infrastructure/pristine-store-service.ts | 13 +-- src/prompts/sdk/prepare.ts | 5 +- src/prompts/sdk/write.ts | 9 +- src/types/file/relativeFilePath.ts | 61 +++++++++++++ src/types/sdk/generation-record.ts | 17 ++-- src/types/sdk/sdk-repository.ts | 13 +-- test/actions/sdk/prepare.test.ts | 7 +- test/actions/sdk/write.test.ts | 13 ++- .../sdk-merge/merge-tree-output.test.ts | 15 +-- test/application/sdk-merge/sdk-merge.test.ts | 91 ++++++++++--------- test/infrastructure/git-service.test.ts | 13 ++- test/infrastructure/merge-service.test.ts | 25 ++--- .../pristine-store-service.test.ts | 15 +-- test/prompts/sdk/write.test.ts | 35 ++++--- test/types/file/relativeFilePath.test.ts | 48 ++++++++++ test/types/sdk/generation-record.test.ts | 5 +- 22 files changed, 348 insertions(+), 177 deletions(-) create mode 100644 src/types/file/relativeFilePath.ts create mode 100644 test/types/file/relativeFilePath.test.ts diff --git a/src/actions/sdk/write.ts b/src/actions/sdk/write.ts index 2e016d6c4..386d9fd13 100644 --- a/src/actions/sdk/write.ts +++ b/src/actions/sdk/write.ts @@ -5,6 +5,7 @@ import { MergeService } from '../../infrastructure/merge-service.js'; import { PristineStoreProblem, PristineStoreService } from '../../infrastructure/pristine-store-service.js'; import { SdkWritePrompts } from '../../prompts/sdk/write.js'; import { DirectoryPath } from '../../types/file/directoryPath.js'; +import { RelativeFilePath } from '../../types/file/relativeFilePath.js'; import { SdkContext } from '../../types/sdk-context.js'; import { CommitId } from '../../types/sdk/commit-id.js'; import { SdkGeneration } from '../../types/sdk/generation-record.js'; @@ -33,7 +34,7 @@ export class SdkWriteAction { case 'record': return this.overwriteAndRecord(destination.repository, sdkContext, tempSdk, generation); case 'merge': - return this.merge(destination.repository, destination.base, sdkContext, tempSdk, generation); + return this.merge(destination.repository, destination.base, tempSdk, generation); } }; @@ -77,7 +78,6 @@ export class SdkWriteAction { private async merge( repository: SdkRepository, base: CommitId, - sdkContext: SdkContext, tempSdk: DirectoryPath, generation: SdkGeneration ): Promise> { @@ -126,7 +126,7 @@ export class SdkWriteAction { repository: SdkRepository, generated: Result, generation: SdkGeneration, - conflicts: readonly string[] + conflicts: readonly RelativeFilePath[] ): Promise { const recorded = await generated.match( (tree) => this.store.recordGeneration(repository, tree, generation, envInfo.getCLIVersion(), conflicts), diff --git a/src/application/sdk-merge/merge-plan.ts b/src/application/sdk-merge/merge-plan.ts index 83b61d19a..e2e7c0ae6 100644 --- a/src/application/sdk-merge/merge-plan.ts +++ b/src/application/sdk-merge/merge-plan.ts @@ -1,3 +1,5 @@ +import { RelativeFilePath } from '../../types/file/relativeFilePath.js'; + export type ConflictKind = /** You and the generator changed the same lines; the file holds markers. */ | 'bothChanged' @@ -13,26 +15,26 @@ export type ConflictKind = | 'other'; /** A file to write, as the blob the merged tree holds for it. */ -export type FileWrite = { path: string; blob: string }; +export type FileWrite = { path: RelativeFilePath; blob: string }; -export type Conflict = { path: string; kind: ConflictKind; message?: string }; +export type Conflict = { path: RelativeFilePath; kind: ConflictKind; message?: string }; -export type Rename = { from: string; to: string; by: 'generator' | 'you' }; +export type Rename = { from: RelativeFilePath; to: RelativeFilePath; by: 'generator' | 'you' }; /** Every path the run touched or left alone, by what happened to it; conflicted paths are listed under `conflicts` instead. */ export type MergeFiles = { /** Files the generator produced for the first time. */ - added: readonly string[]; + added: readonly RelativeFilePath[]; /** Unedited files replaced by their new version, or moved to the generator's new name. */ - updated: readonly string[]; + updated: readonly RelativeFilePath[]; /** Edited files the generator also changed, merged without conflict. */ - merged: readonly string[]; + merged: readonly RelativeFilePath[]; /** Unedited files the generator stopped producing. */ - removed: readonly string[]; + removed: readonly RelativeFilePath[]; /** Edited files the generator left alone. */ - kept: readonly string[]; + kept: readonly RelativeFilePath[]; /** Files neither side changed. */ - unchanged: readonly string[]; + unchanged: readonly RelativeFilePath[]; }; /** @@ -41,14 +43,14 @@ export type MergeFiles = { */ export class MergePlan { public readonly writes: readonly FileWrite[]; - public readonly deletes: readonly string[]; + public readonly deletes: readonly RelativeFilePath[]; public readonly conflicts: readonly Conflict[]; public readonly renames: readonly Rename[]; public readonly files: MergeFiles; constructor( writes: readonly FileWrite[], - deletes: readonly string[], + deletes: readonly RelativeFilePath[], conflicts: readonly Conflict[], renames: readonly Rename[], files: MergeFiles @@ -64,7 +66,7 @@ export class MergePlan { return this.conflicts.length > 0; } - public conflictedPaths(): string[] { + public conflictedPaths(): RelativeFilePath[] { return this.conflicts.map((conflict) => conflict.path); } @@ -84,9 +86,9 @@ export class MergePlan { } /** Whether the file at `path` is written with conflict markers in it. */ - public hasMarkers(path: string): boolean { + public hasMarkers(path: RelativeFilePath): boolean { return this.conflicts.some( - (conflict) => conflict.path === path && (conflict.kind === 'bothChanged' || conflict.kind === 'bothAdded') + (conflict) => conflict.path.isEqual(path) && (conflict.kind === 'bothChanged' || conflict.kind === 'bothAdded') ); } } diff --git a/src/application/sdk-merge/merge-tree-output.ts b/src/application/sdk-merge/merge-tree-output.ts index a1cfdeec1..6fe90ec84 100644 --- a/src/application/sdk-merge/merge-tree-output.ts +++ b/src/application/sdk-merge/merge-tree-output.ts @@ -1,10 +1,11 @@ import { TextDecoder } from 'node:util'; +import { RelativeFilePath } from '../../types/file/relativeFilePath.js'; /** One conflicted path's version on one side: stage 1 is the base, 2 yours, 3 the generator's. */ -export type StageEntry = { mode: string; oid: string; stage: 1 | 2 | 3; path: string }; +export type StageEntry = { mode: string; oid: string; stage: 1 | 2 | 3; path: RelativeFilePath }; /** One of git's informational messages, such as `CONFLICT (modify/delete)` for a path. */ -export type MergeMessage = { paths: string[]; type: string; message: string }; +export type MergeMessage = { paths: RelativeFilePath[]; type: string; message: string }; export type MergeTreeOutput = { tree: string; stages: StageEntry[]; messages: MergeMessage[] }; @@ -21,12 +22,12 @@ export function parseMergeTreeOutput(output: Uint8Array): MergeTreeOutput { for (; index < fields.length && fields[index] !== ''; index += 1) { const [meta, path] = fields[index].split('\t'); const [mode, oid, stage] = meta.split(' '); - stages.push({ mode, oid, stage: Number(stage) as StageEntry['stage'], path }); + stages.push({ mode, oid, stage: Number(stage) as StageEntry['stage'], path: RelativeFilePath.fromGit(path) }); } const messages: MergeMessage[] = []; for (index += 1; index < fields.length && fields[index] !== ''; ) { const count = Number(fields[index]); - const paths = fields.slice(index + 1, index + 1 + count); + const paths = fields.slice(index + 1, index + 1 + count).map((field) => RelativeFilePath.fromGit(field)); const type = fields[index + 1 + count]; const message = fields[index + 2 + count]; messages.push({ paths, type, message: message.trim() }); diff --git a/src/application/sdk-merge/sdk-merge.ts b/src/application/sdk-merge/sdk-merge.ts index 5bcbb7cce..7ab3b397f 100644 --- a/src/application/sdk-merge/sdk-merge.ts +++ b/src/application/sdk-merge/sdk-merge.ts @@ -1,8 +1,14 @@ +import { RelativeFilePath } from '../../types/file/relativeFilePath.js'; import { Conflict, ConflictKind, FileWrite, MergeFiles, MergePlan, Rename } from './merge-plan.js'; import { MergeTreeOutput } from './merge-tree-output.js'; /** How a path changed between two trees, as `diff-tree` reports it. */ -export type Change = { status: 'A' | 'M' | 'D' | 'T' | 'R' | 'C'; path: string; from?: string; blob?: string }; +export type Change = { + status: 'A' | 'M' | 'D' | 'T' | 'R' | 'C'; + path: RelativeFilePath; + from?: RelativeFilePath; + blob?: string; +}; export type MergeInputs = { merge: MergeTreeOutput; @@ -13,7 +19,7 @@ export type MergeInputs = { /** Disk to the merged tree: what the run writes and removes. No renames. */ applied: readonly Change[]; /** Every path the base holds. */ - basePaths: readonly string[]; + basePaths: readonly RelativeFilePath[]; }; const CONFLICT_PREFIX = 'CONFLICT ('; @@ -47,7 +53,8 @@ function kindOf(type: string, message: string, stages: ReadonlySet): Con function conflictsOf(merge: MergeTreeOutput): Conflict[] { const stagesByPath = new Map>(); for (const entry of merge.stages) { - stagesByPath.set(entry.path, (stagesByPath.get(entry.path) ?? new Set()).add(entry.stage)); + const key = entry.path.toString(); + stagesByPath.set(key, (stagesByPath.get(key) ?? new Set()).add(entry.stage)); } const byPath = new Map(); for (const { paths, type, message } of merge.messages) { @@ -55,42 +62,45 @@ function conflictsOf(merge: MergeTreeOutput): Conflict[] { continue; } for (const path of paths) { - const kind = kindOf(type, message, stagesByPath.get(path) ?? new Set()); - const known = byPath.get(path); + const key = path.toString(); + const kind = kindOf(type, message, stagesByPath.get(key) ?? new Set()); + const known = byPath.get(key); if (known === undefined || RANK[kind] > RANK[known.kind]) { - byPath.set(path, kind === 'other' ? { path, kind, message } : { path, kind }); + byPath.set(key, kind === 'other' ? { path, kind, message } : { path, kind }); } } } return [...byPath.values()]; } -const changedPaths = (changes: readonly Change[]) => +/** Each change under every path it names, so a rename is found by either name. */ +const changedPaths = (changes: readonly Change[]): Map => new Map( - changes.flatMap((change) => + changes.flatMap((change): [string, Change][] => change.from === undefined - ? [[change.path, change]] + ? [[change.path.toString(), change]] : [ - [change.from, change], - [change.path, change] + [change.from.toString(), change], + [change.path.toString(), change] ] ) ); /** * What git's merge means for the SDK directory: the writes and deletions that bring the disk - * to the merged tree, the conflicts by kind, the renames each side made, and the counts. + * to the merged tree, the conflicts by kind, the renames each side made, and every other path + * by what happened to it. */ export function planMerge(inputs: MergeInputs): MergePlan { const conflicts = conflictsOf(inputs.merge); - const conflicted = new Set(conflicts.map((conflict) => conflict.path)); + const conflicted = new Set(conflicts.map((conflict) => conflict.path.toString())); const yours = changedPaths(inputs.yours); const generated = changedPaths(inputs.generated); - const base = new Set(inputs.basePaths); + const base = new Set(inputs.basePaths.map((path) => path.toString())); const writes: FileWrite[] = []; - const deletes: string[] = []; - const files: Record = { + const deletes: RelativeFilePath[] = []; + const files: Record = { added: [], updated: [], merged: [], @@ -100,33 +110,35 @@ export function planMerge(inputs: MergeInputs): MergePlan { }; for (const change of inputs.applied) { + const key = change.path.toString(); if (change.status === 'D') { deletes.push(change.path); - if (!conflicted.has(change.path)) { + if (!conflicted.has(key)) { files.removed.push(change.path); } continue; } writes.push({ path: change.path, blob: change.blob! }); - if (conflicted.has(change.path)) { + if (conflicted.has(key)) { continue; } - if (!base.has(change.path) && generated.get(change.path)?.status === 'A') { + if (!base.has(key) && generated.get(key)?.status === 'A') { files.added.push(change.path); - } else if (yours.get(change.path)?.status === 'M' && generated.has(change.path)) { + } else if (yours.get(key)?.status === 'M' && generated.has(key)) { files.merged.push(change.path); } else { files.updated.push(change.path); } } - for (const [path, change] of yours) { - if (change.status === 'M' && !generated.has(path) && !conflicted.has(path)) { - files.kept.push(path); + for (const [key, change] of yours) { + if (change.status === 'M' && !generated.has(key) && !conflicted.has(key)) { + files.kept.push(change.path); } } - for (const path of base) { - if (!yours.has(path) && !generated.has(path)) { + for (const path of inputs.basePaths) { + const key = path.toString(); + if (!yours.has(key) && !generated.has(key)) { files.unchanged.push(path); } } diff --git a/src/infrastructure/git-service.ts b/src/infrastructure/git-service.ts index c1bc43103..0177c585b 100644 --- a/src/infrastructure/git-service.ts +++ b/src/infrastructure/git-service.ts @@ -1,8 +1,8 @@ import { Buffer } from 'node:buffer'; import fs from 'node:fs'; -import path from 'node:path'; import { execa, ExecaError } from 'execa'; import { StageEntry } from '../application/sdk-merge/merge-tree-output.js'; +import { RelativeFilePath } from '../types/file/relativeFilePath.js'; import { DirectoryPath } from '../types/file/directoryPath.js'; import { FileName } from '../types/file/fileName.js'; import { FilePath } from '../types/file/filePath.js'; @@ -13,14 +13,14 @@ import { TreeId } from '../types/sdk/tree-id.js'; import { errorMessage } from '../utils/error-utils.js'; import { withDirPath } from './tmp-extensions.js'; -export type TreeEntry = { mode: string; type: 'blob' | 'tree' | 'commit'; oid: string; path: string }; +export type TreeEntry = { mode: string; type: 'blob' | 'tree' | 'commit'; oid: string; path: RelativeFilePath }; /** One line of `diff-tree --raw`: how `path` differs between two trees, and the blob it holds in the second. */ export type DiffEntry = { status: 'A' | 'M' | 'D' | 'T' | 'R' | 'C'; - path: string; + path: RelativeFilePath; /** The path in the first tree, for a rename or copy. */ - from?: string; + from?: RelativeFilePath; /** The blob in the second tree; absent for a deletion. */ blob?: string; }; @@ -72,7 +72,7 @@ export class GitService { public async treeOf( root: DirectoryPath, directory: DirectoryPath, - include: 'all' | readonly string[] + include: 'all' | readonly RelativeFilePath[] ): Promise { const gitDir = await this.gitDir(root); return withDirPath(async (temporary) => { @@ -81,9 +81,9 @@ export class GitService { this.run(directory, ['--git-dir', gitDir, '--work-tree', directory.toString(), ...args], env, temporary, input); await inWorkTree(['add', '--all', ...(include === 'all' ? ['--force'] : []), '--', '.']); const forced = - include === 'all' ? [] : include.filter((relative) => fs.existsSync(path.join(directory.toString(), relative))); + include === 'all' ? [] : include.filter((relative) => fs.existsSync(relative.in(directory).toString())); if (forced.length > 0) { - await inWorkTree(['add', '--force', '--pathspec-from-file=-', '--pathspec-file-nul'], forced.join('\0')); + await inWorkTree(['add', '--force', '--pathspec-from-file=-', '--pathspec-file-nul'], forced.map(String).join('\0')); } const { stdout } = await inWorkTree(['write-tree']); return TreeId.fromGit(stdout.trim()); @@ -162,7 +162,7 @@ export class GitService { .map((entry) => { const [meta, filePath] = entry.split('\t'); const [mode, type, oid] = meta.split(' '); - return { mode, type: type as TreeEntry['type'], oid, path: filePath }; + return { mode, type: type as TreeEntry['type'], oid, path: RelativeFilePath.fromGit(filePath) }; }); } @@ -232,10 +232,15 @@ export class GitService { const status = statusWithScore[0] as DiffEntry['status']; const first = fields[index + 1]; if (status === 'R' || status === 'C') { - entries.push({ status, from: first, path: fields[index + 2], blob: dstBlob }); + entries.push({ + status, + from: RelativeFilePath.fromGit(first), + path: RelativeFilePath.fromGit(fields[index + 2]), + blob: dstBlob + }); index += 3; } else { - entries.push({ status, path: first, ...(status === 'D' ? {} : { blob: dstBlob }) }); + entries.push({ status, path: RelativeFilePath.fromGit(first), ...(status === 'D' ? {} : { blob: dstBlob }) }); index += 2; } } @@ -249,7 +254,7 @@ export class GitService { * keep it beside the others. */ public async stageConflicts(root: DirectoryPath, entries: readonly StageEntry[]): Promise { - const removals = [...new Set(entries.map((entry) => entry.path))].map((path) => `0 ${'0'.repeat(40)} 0\t${path}`); + const removals = [...new Set(entries.map((entry) => entry.path.toString()))].map((path) => `0 ${'0'.repeat(40)} 0\t${path}`); const stages = entries.map((entry) => `${entry.mode} ${entry.oid} ${entry.stage}\t${entry.path}`); await this.run( root, diff --git a/src/infrastructure/merge-service.ts b/src/infrastructure/merge-service.ts index 820f76d15..d5fb02c29 100644 --- a/src/infrastructure/merge-service.ts +++ b/src/infrastructure/merge-service.ts @@ -53,17 +53,17 @@ export class MergeService { /** Writes and removes what the plan says, in the SDK directory, with the CLI's labels on any markers. */ public async apply(repository: SdkRepository, plan: MergePlan): Promise> { - const sdk = repository.sdkDirectory().toString(); + const sdk = repository.sdkDirectory(); try { const blobs = await this.git.readBlobs(repository.root(), [...new Set(plan.writes.map((write) => write.blob))]); for (const write of plan.writes) { const content = blobs.get(write.blob)!; - const target = path.join(sdk, write.path); + const target = write.path.in(sdk).toString(); await fs.promises.mkdir(path.dirname(target), { recursive: true }); await fs.promises.writeFile(target, plan.hasMarkers(write.path) ? relabelMarkers(content) : content); } for (const relative of plan.deletes) { - await fs.promises.rm(path.join(sdk, relative), { force: true }); + await fs.promises.rm(relative.in(sdk).toString(), { force: true }); } return ok(undefined); } catch (error) { diff --git a/src/infrastructure/pristine-store-service.ts b/src/infrastructure/pristine-store-service.ts index e5db42b07..be3250c6b 100644 --- a/src/infrastructure/pristine-store-service.ts +++ b/src/infrastructure/pristine-store-service.ts @@ -5,6 +5,7 @@ import { hasConflictMarkers } from '../application/sdk-merge/markers.js'; import { DirectoryPath } from '../types/file/directoryPath.js'; import { FileName } from '../types/file/fileName.js'; import { FilePath } from '../types/file/filePath.js'; +import { RelativeFilePath } from '../types/file/relativeFilePath.js'; import { CommitId } from '../types/sdk/commit-id.js'; import { describeGeneration, GenerationRecord, SdkGeneration } from '../types/sdk/generation-record.js'; import { SdkRepository } from '../types/sdk/sdk-repository.js'; @@ -27,7 +28,7 @@ export type SdkDirectoryState = | { kind: 'noRecord'; repository: SdkRepository } | { kind: 'otherGeneration'; repository: SdkRepository; recorded: SdkGeneration } | { kind: 'snapshotMissing'; repository: SdkRepository; commit: CommitId } - | { kind: 'unresolved'; repository: SdkRepository; paths: string[] } + | { kind: 'unresolved'; repository: SdkRepository; paths: RelativeFilePath[] } | { kind: 'ready'; repository: SdkRepository; base: CommitId }; const RECORD_DIRECTORY = '.apimatic'; @@ -173,7 +174,7 @@ export class PristineStoreService { generated: TreeId, generation: SdkGeneration, cliVersion: string, - conflicts: readonly string[] + conflicts: readonly RelativeFilePath[] ): Promise> { const pristine = await this.recordPristine(repository, generated, generation); if (pristine.isErr()) { @@ -212,7 +213,7 @@ export class PristineStoreService { } const entries = (await this.git.listTree(root, commit)).filter((entry) => entry.type === 'blob'); const blobs = await this.git.readBlobs(root, [...new Set(entries.map((entry) => entry.oid))]); - return ok(new Map(entries.map((entry) => [entry.path, blobs.get(entry.oid)!]))); + return ok(new Map(entries.map((entry) => [entry.path.toString(), blobs.get(entry.oid)!]))); } catch (error) { return err(gitFailed(errorMessage(error))); } @@ -256,10 +257,10 @@ export class PristineStoreService { } // The files the record lists as conflicted that still hold the markers, so a run never merges over them. - private async unresolvedConflicts(repository: SdkRepository, record: GenerationRecord): Promise { - const unresolved: string[] = []; + private async unresolvedConflicts(repository: SdkRepository, record: GenerationRecord): Promise { + const unresolved: RelativeFilePath[] = []; for (const relative of record.conflictedPaths()) { - const file = repository.file(relative); + const file = relative.in(repository.sdkDirectory()); if ((await this.fileService.fileExists(file)) && hasConflictMarkers(await this.fileService.getContents(file))) { unresolved.push(relative); } diff --git a/src/prompts/sdk/prepare.ts b/src/prompts/sdk/prepare.ts index 879920fca..0d6cdf617 100644 --- a/src/prompts/sdk/prepare.ts +++ b/src/prompts/sdk/prepare.ts @@ -1,5 +1,6 @@ import { isCancel, confirm, log } from '@clack/prompts'; import { DirectoryPath } from '../../types/file/directoryPath.js'; +import { RelativeFilePath } from '../../types/file/relativeFilePath.js'; import { CommitId } from '../../types/sdk/commit-id.js'; import { describeGeneration, SdkGeneration } from '../../types/sdk/generation-record.js'; import { GitVersion } from '../../types/sdk/git-version.js'; @@ -62,12 +63,12 @@ export class SdkPreparePrompts { log.warn(message); } - public conflictsUnresolved(paths: readonly string[], repository: SdkRepository) { + public conflictsUnresolved(paths: readonly RelativeFilePath[], repository: SdkRepository) { const one = paths.length === 1; const message = `${plural(paths.length, 'file')} still hold${one ? 's' : ''} conflict markers from the last run. ` + `Resolve ${one ? 'it' : 'them'} and remove the markers, then rerun:\n` + - paths.map((path) => ` ${f.relativePath(repository.file(path))}`).join('\n'); + paths.map((path) => ` ${f.relativePath(path.in(repository.sdkDirectory()))}`).join('\n'); log.error(message); } } diff --git a/src/prompts/sdk/write.ts b/src/prompts/sdk/write.ts index e72848001..200e39533 100644 --- a/src/prompts/sdk/write.ts +++ b/src/prompts/sdk/write.ts @@ -1,6 +1,7 @@ import { log } from '@clack/prompts'; import { Conflict, ConflictKind, MergeFiles, MergePlan } from '../../application/sdk-merge/merge-plan.js'; import { DirectoryPath } from '../../types/file/directoryPath.js'; +import { RelativeFilePath } from '../../types/file/relativeFilePath.js'; import { SdkSaveProblem } from '../../types/sdk-context.js'; import { SdkRepository } from '../../types/sdk/sdk-repository.js'; import { listedInProse } from '../../utils/string-utils.js'; @@ -27,8 +28,8 @@ const YOUR_FILES: Readonly>> = { const plural = (count: number, noun: string) => `${count} ${noun}${count === 1 ? '' : 's'}`; -const listed = (repository: SdkRepository, paths: readonly string[]) => - paths.map((relative) => ` ${f.relativePath(repository.file(relative))}`); +const listed = (repository: SdkRepository, paths: readonly RelativeFilePath[]) => + paths.map((relative) => ` ${f.relativePath(relative.in(repository.sdkDirectory()))}`); export class SdkWritePrompts { public sdkNotSaved(problem: SdkSaveProblem) { @@ -84,7 +85,9 @@ export class SdkWritePrompts { public conflicts(conflicts: readonly Conflict[], repository: SdkRepository) { const lines = conflicts.map( (conflict) => - `${f.relativePath(repository.file(conflict.path))}\n ${conflict.message ?? CONFLICT_LABELS[conflict.kind]}` + `${f.relativePath(conflict.path.in(repository.sdkDirectory()))}\n ${ + conflict.message ?? CONFLICT_LABELS[conflict.kind] + }` ); const marked = conflicts.some((conflict) => MARKED_KINDS.includes(conflict.kind)); const unmarked = conflicts.some((conflict) => !MARKED_KINDS.includes(conflict.kind)); diff --git a/src/types/file/relativeFilePath.ts b/src/types/file/relativeFilePath.ts new file mode 100644 index 000000000..191f89797 --- /dev/null +++ b/src/types/file/relativeFilePath.ts @@ -0,0 +1,61 @@ +import path from 'node:path'; +import { DirectoryPath } from './directoryPath.js'; +import { FileName } from './fileName.js'; +import { FilePath } from './filePath.js'; + +/** + * A file's place below some directory, spelt as git names a path inside a tree: `/` between the + * segments, no leading `/`, no `.` or `..`. The spelling is the same on every OS, so it goes to + * git and into a record unchanged; `in(directory)` is the file on disk. + */ +export class RelativeFilePath { + private readonly value: string; + + private constructor(value: string) { + this.value = value; + } + + public static create(value: string): RelativeFilePath | undefined { + const segments = value.split('/'); + const wellFormed = + !value.includes('\\') && + !path.isAbsolute(value) && + segments.every((segment) => segment !== '' && segment !== '.' && segment !== '..'); + return wellFormed ? new RelativeFilePath(value) : undefined; + } + + /** From git's own output, which names a path no other way. */ + public static fromGit(value: string): RelativeFilePath { + const created = RelativeFilePath.create(value); + if (created === undefined) { + throw new Error(`git named a path that is not relative: ${value}`); + } + return created; + } + + public in(directory: DirectoryPath): FilePath { + return new FilePath(directory.join(path.posix.dirname(this.value)), this.name()); + } + + /** The same file seen from above `prefix`, a directory spelt the same way, or from the same place when `prefix` is empty. */ + public below(prefix: string): RelativeFilePath { + return prefix === '' ? this : new RelativeFilePath(`${prefix}/${this.value}`); + } + + public name(): FileName { + return new FileName(path.posix.basename(this.value)); + } + + public isEqual(other: RelativeFilePath): boolean { + return this.value === other.value; + } + + /** By code point, as `FileName.compare` is and for its reason. */ + public compare(other: RelativeFilePath): number { + return this.value < other.value ? -1 : Number(this.value > other.value); + } + + public toString(): string { + return this.value; + } +} diff --git a/src/types/sdk/generation-record.ts b/src/types/sdk/generation-record.ts index 5f9d6c405..20eb49f3c 100644 --- a/src/types/sdk/generation-record.ts +++ b/src/types/sdk/generation-record.ts @@ -1,3 +1,4 @@ +import { RelativeFilePath } from '../file/relativeFilePath.js'; import { CommitId } from './commit-id.js'; import { CodeGenerationVersion, Language, Stability } from './generate.js'; @@ -26,14 +27,14 @@ export class GenerationRecord { private readonly pristine: CommitId; private readonly generatedAt: Date; private readonly cliVersion: string; - private readonly conflicts: readonly string[]; + private readonly conflicts: readonly RelativeFilePath[]; private constructor( generation: SdkGeneration, pristine: CommitId, generatedAt: Date, cliVersion: string, - conflicts: readonly string[] + conflicts: readonly RelativeFilePath[] ) { this.generation = generation; this.pristine = pristine; @@ -48,7 +49,7 @@ export class GenerationRecord { pristine: CommitId, generatedAt: Date, cliVersion: string, - conflicts: readonly string[] = [] + conflicts: readonly RelativeFilePath[] = [] ): GenerationRecord { return new GenerationRecord(generation, pristine, generatedAt, cliVersion, conflicts); } @@ -79,12 +80,16 @@ export class GenerationRecord { ) { return undefined; } + const conflicts = record.conflicts.map((value) => RelativeFilePath.create(value)); + if (!conflicts.every((path): path is RelativeFilePath => path !== undefined)) { + return undefined; + } return new GenerationRecord( { language: record.language, codegenVersion: record.codegenVersion, stability: record.stability }, pristine, generatedAt, record.cliVersion, - record.conflicts + conflicts ); } @@ -96,7 +101,7 @@ export class GenerationRecord { return this.generation; } - public conflictedPaths(): readonly string[] { + public conflictedPaths(): readonly RelativeFilePath[] { return this.conflicts; } @@ -123,7 +128,7 @@ export class GenerationRecord { pristine: this.pristine.toString(), generatedAt: this.generatedAt.toISOString(), cliVersion: this.cliVersion, - conflicts: this.conflicts + conflicts: this.conflicts.map((path) => path.toString()) }, null, 2 diff --git a/src/types/sdk/sdk-repository.ts b/src/types/sdk/sdk-repository.ts index 8e545bf78..61c205447 100644 --- a/src/types/sdk/sdk-repository.ts +++ b/src/types/sdk/sdk-repository.ts @@ -1,7 +1,6 @@ import path from 'node:path'; import { DirectoryPath, posix } from '../file/directoryPath.js'; -import { FileName } from '../file/fileName.js'; -import { FilePath } from '../file/filePath.js'; +import { RelativeFilePath } from '../file/relativeFilePath.js'; import { SnapshotRef } from './snapshot-ref.js'; /** An SDK directory together with the git repository that encloses it, which is where its snapshots live. */ @@ -39,15 +38,9 @@ export class SdkRepository { return SnapshotRef.previous(this.sdkPath()); } - /** The file at `relative` in the SDK directory, where `relative` has `/` between its segments as git and the record name it. */ - public file(relative: string): FilePath { - return new FilePath(this.sdk.join(path.posix.dirname(relative)), new FileName(path.posix.basename(relative))); - } - /** A path below the SDK directory, with `/` between segments, as git names it from the repository root. */ - public rootRelative(relative: string): string { - const sdkPath = this.sdkPath(); - return sdkPath === '' ? relative : `${sdkPath}/${relative}`; + public rootRelative(relative: RelativeFilePath): RelativeFilePath { + return relative.below(this.sdkPath()); } private sdkPath(): string { diff --git a/test/actions/sdk/prepare.test.ts b/test/actions/sdk/prepare.test.ts index 8ec5878ad..1481cb54c 100644 --- a/test/actions/sdk/prepare.test.ts +++ b/test/actions/sdk/prepare.test.ts @@ -1,3 +1,4 @@ +import { RelativeFilePath } from '../../../src/types/file/relativeFilePath.js'; import fs from 'fs'; import os from 'os'; import path from 'path'; @@ -24,6 +25,8 @@ const STABLE: SdkGeneration = { }; const BETA: SdkGeneration = { ...STABLE, stability: Stability.BETA }; +const rel = (value: string) => RelativeFilePath.fromGit(value); + describe('SdkPrepareAction', () => { let root: string; let sdkContext: SdkContext; @@ -53,7 +56,7 @@ describe('SdkPrepareAction', () => { await git.init(new DirectoryPath(root)); const repository = (await store.findRepository(sdk()))!; const tree = (await store.snapshotGenerated(repository, sdk()))._unsafeUnwrap(); - (await store.recordGeneration(repository, tree, STABLE, '0.0.0', conflicts))._unsafeUnwrap(); + (await store.recordGeneration(repository, tree, STABLE, '0.0.0', conflicts.map(rel)))._unsafeUnwrap(); return repository; }; @@ -173,7 +176,7 @@ describe('SdkPrepareAction', () => { const conflictsUnresolved = sinon.stub(SdkPreparePrompts.prototype, 'conflictsUnresolved'); expect((await execute()).isFailed()).to.be.true; - expect(conflictsUnresolved.firstCall.args[0]).to.deep.equal(['a.py']); + expect(conflictsUnresolved.firstCall.args[0].map(String)).to.deep.equal(['a.py']); }); it('refuses an SDK directory nested in one that has snapshots', async () => { diff --git a/test/actions/sdk/write.test.ts b/test/actions/sdk/write.test.ts index 7679c9b28..c71e8658e 100644 --- a/test/actions/sdk/write.test.ts +++ b/test/actions/sdk/write.test.ts @@ -1,3 +1,4 @@ +import { RelativeFilePath } from '../../../src/types/file/relativeFilePath.js'; import fs from 'fs'; import os from 'os'; import path from 'path'; @@ -44,6 +45,8 @@ const MODEL_V1_EDITED = MODEL_V1.replace('# Price in USD.', '# Price in USD, VAT type Files = Record; +const rel = (value: string) => RelativeFilePath.fromGit(value); + describe('SdkWriteAction', () => { let root: string; let sdkContext: SdkContext; @@ -182,7 +185,11 @@ describe('SdkWriteAction', () => { expect(read('README.md')).to.equal('# sdk v2'); expect(read('models/author.py')).to.equal('class Author: ...'); expect(read('notes.txt')).to.equal('mine'); - expect(sdkMerged.firstCall.args[1].files).to.deep.include({ + expect( + Object.fromEntries( + Object.entries(sdkMerged.firstCall.args[1].files).map(([kind, paths]) => [kind, paths.map(String)]) + ) + ).to.deep.include({ added: ['models/author.py'], merged: ['models/book.py'], removed: [], @@ -209,12 +216,12 @@ describe('SdkWriteAction', () => { expect(result.isFailed()).to.be.true; expect(result.getExitCode()).to.equal(1); expect(result.getMessage()).to.equal('Resolve the conflicts and rerun'); - expect(conflicts.firstCall.args[0]).to.deep.equal([{ path: 'models/book.py', kind: 'bothChanged' }]); + expect(conflicts.firstCall.args[0]).to.deep.equal([{ path: rel('models/book.py'), kind: 'bothChanged' }]); expect(read('models/book.py')).to.contain(`${YOURS_MARKER}\n # Price in USD, VAT included.\n=======\n`); expect(read('models/book.py')).to.contain(' # Retail price in US dollars.\n'); expect(read('models/book.py')).to.contain(`${GENERATED_MARKER}\n`); expect(read('README.md')).to.equal('# sdk v2'); - expect(record().conflictedPaths()).to.deep.equal(['models/book.py']); + expect(record().conflictedPaths().map(String)).to.deep.equal(['models/book.py']); expect(result.getError()?.conflictsByKind()).to.include({ bothChanged: 1, noLongerGenerated: 0 }); const cwd = repository.root().toString(); expect((await execa('git', ['status', '--porcelain'], { cwd })).stdout).to.contain( diff --git a/test/application/sdk-merge/merge-tree-output.test.ts b/test/application/sdk-merge/merge-tree-output.test.ts index cab9fa1da..adbd37629 100644 --- a/test/application/sdk-merge/merge-tree-output.test.ts +++ b/test/application/sdk-merge/merge-tree-output.test.ts @@ -1,3 +1,4 @@ +import { RelativeFilePath } from '../../../src/types/file/relativeFilePath.js'; import { TextEncoder } from 'node:util'; import { expect } from 'chai'; import { parseMergeTreeOutput } from '../../../src/application/sdk-merge/merge-tree-output.js'; @@ -10,6 +11,8 @@ const THEIRS = '0c02cccd4ca03aab5fcf3996660d5c0f5020a107'; // As git 2.47 printed it for a content conflict in same.txt and a file deleted on one side. const output = (...fields: string[]) => new TextEncoder().encode(fields.join('\0')); +const rel = (value: string) => RelativeFilePath.fromGit(value); + describe('parseMergeTreeOutput', () => { it('reads the tree, the stages of each conflicted path and the messages', () => { const parsed = parseMergeTreeOutput( @@ -39,13 +42,13 @@ describe('parseMergeTreeOutput', () => { expect(parsed.tree).to.equal(TREE); expect(parsed.stages).to.deep.equal([ - { mode: '100644', oid: BASE, stage: 1, path: 'same.txt' }, - { mode: '100644', oid: OURS, stage: 2, path: 'same.txt' }, - { mode: '100644', oid: THEIRS, stage: 3, path: 'same.txt' }, - { mode: '100644', oid: BASE, stage: 1, path: 'gone.txt' }, - { mode: '100644', oid: THEIRS, stage: 3, path: 'gone.txt' } + { mode: '100644', oid: BASE, stage: 1, path: rel('same.txt') }, + { mode: '100644', oid: OURS, stage: 2, path: rel('same.txt') }, + { mode: '100644', oid: THEIRS, stage: 3, path: rel('same.txt') }, + { mode: '100644', oid: BASE, stage: 1, path: rel('gone.txt') }, + { mode: '100644', oid: THEIRS, stage: 3, path: rel('gone.txt') } ]); - expect(parsed.messages.map((message) => [message.paths, message.type])).to.deep.equal([ + expect(parsed.messages.map((message) => [message.paths.map(String), message.type])).to.deep.equal([ [['same.txt'], 'Auto-merging'], [['same.txt'], 'CONFLICT (contents)'], [['gone.txt'], 'CONFLICT (modify/delete)'] diff --git a/test/application/sdk-merge/sdk-merge.test.ts b/test/application/sdk-merge/sdk-merge.test.ts index a461a6add..bd2b59f01 100644 --- a/test/application/sdk-merge/sdk-merge.test.ts +++ b/test/application/sdk-merge/sdk-merge.test.ts @@ -1,3 +1,4 @@ +import { RelativeFilePath } from '../../../src/types/file/relativeFilePath.js'; import { MergePlan } from '../../../src/application/sdk-merge/merge-plan.js'; import { expect } from 'chai'; import { MergeInputs, planMerge } from '../../../src/application/sdk-merge/sdk-merge.js'; @@ -18,43 +19,45 @@ const inputs = (overrides: Partial): MergeInputs => ({ yours: [], generated: [], applied: [], - basePaths: [], + basePaths: [].map(rel), ...overrides }); const counts = (plan: MergePlan) => Object.fromEntries(Object.entries(plan.files).map(([kind, paths]) => [kind, paths.length])); +const rel = (value: string) => RelativeFilePath.fromGit(value); + describe('planMerge', () => { it('turns the diff from the disk to the merged tree into writes and deletions, counted by who changed what', () => { const plan = planMerge( inputs({ - basePaths: ['a.ts', 'b.ts', 'c.ts', 'd.ts', 'e.ts'], + basePaths: ['a.ts', 'b.ts', 'c.ts', 'd.ts', 'e.ts'].map(rel), yours: [ - { status: 'M', path: 'b.ts', blob: blob('by') }, - { status: 'M', path: 'c.ts', blob: blob('cy') } + { status: 'M', path: rel('b.ts'), blob: blob('by') }, + { status: 'M', path: rel('c.ts'), blob: blob('cy') } ], generated: [ - { status: 'M', path: 'a.ts', blob: blob('ag') }, - { status: 'M', path: 'b.ts', blob: blob('bg') }, - { status: 'D', path: 'd.ts' }, - { status: 'A', path: 'new.ts', blob: blob('ng') } + { status: 'M', path: rel('a.ts'), blob: blob('ag') }, + { status: 'M', path: rel('b.ts'), blob: blob('bg') }, + { status: 'D', path: rel('d.ts') }, + { status: 'A', path: rel('new.ts'), blob: blob('ng') } ], applied: [ - { status: 'M', path: 'a.ts', blob: blob('ag') }, - { status: 'M', path: 'b.ts', blob: blob('bm') }, - { status: 'D', path: 'd.ts' }, - { status: 'A', path: 'new.ts', blob: blob('ng') } + { status: 'M', path: rel('a.ts'), blob: blob('ag') }, + { status: 'M', path: rel('b.ts'), blob: blob('bm') }, + { status: 'D', path: rel('d.ts') }, + { status: 'A', path: rel('new.ts'), blob: blob('ng') } ] }) ); expect(plan.writes).to.deep.equal([ - { path: 'a.ts', blob: blob('ag') }, - { path: 'b.ts', blob: blob('bm') }, - { path: 'new.ts', blob: blob('ng') } + { path: rel('a.ts'), blob: blob('ag') }, + { path: rel('b.ts'), blob: blob('bm') }, + { path: rel('new.ts'), blob: blob('ng') } ]); - expect(plan.deletes).to.deep.equal(['d.ts']); + expect(plan.deletes).to.deep.equal(['d.ts'].map(rel)); expect(counts(plan)).to.deep.equal({ added: 1, updated: 1, merged: 1, removed: 1, kept: 1, unchanged: 1 }); expect(plan.hasConflicts()).to.be.false; }); @@ -64,71 +67,71 @@ describe('planMerge', () => { inputs({ merge: merge({ stages: [ - { mode: '100644', oid: blob('1'), stage: 1, path: 'deleted-by-you.ts' }, - { mode: '100644', oid: blob('3'), stage: 3, path: 'deleted-by-you.ts' }, - { mode: '100644', oid: blob('1'), stage: 1, path: 'dropped.ts' }, - { mode: '100644', oid: blob('2'), stage: 2, path: 'dropped.ts' } + { mode: '100644', oid: blob('1'), stage: 1, path: rel('deleted-by-you.ts') }, + { mode: '100644', oid: blob('3'), stage: 3, path: rel('deleted-by-you.ts') }, + { mode: '100644', oid: blob('1'), stage: 1, path: rel('dropped.ts') }, + { mode: '100644', oid: blob('2'), stage: 2, path: rel('dropped.ts') } ], messages: [ - { paths: ['same.ts'], type: 'Auto-merging', message: 'Auto-merging same.ts' }, + { paths: ['same.ts'].map(rel), type: 'Auto-merging', message: 'Auto-merging same.ts' }, { - paths: ['same.ts'], + paths: ['same.ts'].map(rel), type: 'CONFLICT (contents)', message: 'CONFLICT (content): Merge conflict in same.ts' }, { - paths: ['added.ts'], + paths: ['added.ts'].map(rel), type: 'CONFLICT (contents)', message: 'CONFLICT (add/add): Merge conflict in added.ts' }, - { paths: ['logo.png'], type: 'CONFLICT (binary)', message: 'warning: Cannot merge binary files: logo.png' }, + { paths: ['logo.png'].map(rel), type: 'CONFLICT (binary)', message: 'warning: Cannot merge binary files: logo.png' }, { - paths: ['logo.png'], + paths: ['logo.png'].map(rel), type: 'CONFLICT (contents)', message: 'CONFLICT (content): Merge conflict in logo.png' }, - { paths: ['deleted-by-you.ts'], type: 'CONFLICT (modify/delete)', message: 'CONFLICT (modify/delete): …' }, - { paths: ['dropped.ts'], type: 'CONFLICT (modify/delete)', message: 'CONFLICT (modify/delete): …' }, + { paths: ['deleted-by-you.ts'].map(rel), type: 'CONFLICT (modify/delete)', message: 'CONFLICT (modify/delete): …' }, + { paths: ['dropped.ts'].map(rel), type: 'CONFLICT (modify/delete)', message: 'CONFLICT (modify/delete): …' }, { - paths: ['x.ts', 'y.ts'], + paths: ['x.ts', 'y.ts'].map(rel), type: 'CONFLICT (rename/rename)', message: 'CONFLICT (rename/rename): a.ts renamed to x.ts and y.ts' } ] }), applied: [ - { status: 'M', path: 'same.ts', blob: blob('m') }, - { status: 'A', path: 'deleted-by-you.ts', blob: blob('3') } + { status: 'M', path: rel('same.ts'), blob: blob('m') }, + { status: 'A', path: rel('deleted-by-you.ts'), blob: blob('3') } ] }) ); expect(plan.conflicts).to.deep.equal([ - { path: 'same.ts', kind: 'bothChanged' }, - { path: 'added.ts', kind: 'bothAdded' }, - { path: 'logo.png', kind: 'binary' }, - { path: 'deleted-by-you.ts', kind: 'deletedByYou' }, - { path: 'dropped.ts', kind: 'noLongerGenerated' }, - { path: 'x.ts', kind: 'other', message: 'CONFLICT (rename/rename): a.ts renamed to x.ts and y.ts' }, - { path: 'y.ts', kind: 'other', message: 'CONFLICT (rename/rename): a.ts renamed to x.ts and y.ts' } + { path: rel('same.ts'), kind: 'bothChanged' }, + { path: rel('added.ts'), kind: 'bothAdded' }, + { path: rel('logo.png'), kind: 'binary' }, + { path: rel('deleted-by-you.ts'), kind: 'deletedByYou' }, + { path: rel('dropped.ts'), kind: 'noLongerGenerated' }, + { path: rel('x.ts'), kind: 'other', message: 'CONFLICT (rename/rename): a.ts renamed to x.ts and y.ts' }, + { path: rel('y.ts'), kind: 'other', message: 'CONFLICT (rename/rename): a.ts renamed to x.ts and y.ts' } ]); - expect(plan.hasMarkers('same.ts')).to.be.true; - expect(plan.hasMarkers('deleted-by-you.ts')).to.be.false; + expect(plan.hasMarkers(rel('same.ts'))).to.be.true; + expect(plan.hasMarkers(rel('deleted-by-you.ts'))).to.be.false; expect(counts(plan)).to.deep.equal({ added: 0, updated: 0, merged: 0, removed: 0, kept: 0, unchanged: 0 }); }); it('reports the renames each side made', () => { const plan = planMerge( inputs({ - basePaths: ['a.ts', 'b.ts'], - yours: [{ status: 'R', from: 'a.ts', path: 'moved/a.ts', blob: blob('a') }], - generated: [{ status: 'R', from: 'b.ts', path: 'b-model.ts', blob: blob('b') }] + basePaths: ['a.ts', 'b.ts'].map(rel), + yours: [{ status: 'R', from: rel('a.ts'), path: rel('moved/a.ts'), blob: blob('a') }], + generated: [{ status: 'R', from: rel('b.ts'), path: rel('b-model.ts'), blob: blob('b') }] }) ); expect(plan.renames).to.deep.equal([ - { from: 'b.ts', to: 'b-model.ts', by: 'generator' }, - { from: 'a.ts', to: 'moved/a.ts', by: 'you' } + { from: rel('b.ts'), to: rel('b-model.ts'), by: 'generator' }, + { from: rel('a.ts'), to: rel('moved/a.ts'), by: 'you' } ]); }); }); diff --git a/test/infrastructure/git-service.test.ts b/test/infrastructure/git-service.test.ts index 9cf86743e..b60b7b647 100644 --- a/test/infrastructure/git-service.test.ts +++ b/test/infrastructure/git-service.test.ts @@ -1,3 +1,4 @@ +import { RelativeFilePath } from '../../src/types/file/relativeFilePath.js'; import fs from 'fs'; import os from 'os'; import path from 'path'; @@ -9,6 +10,8 @@ import { DirectoryPath } from '../../src/types/file/directoryPath.js'; const text = (content: Uint8Array | undefined) => content === undefined ? undefined : new TextDecoder().decode(content); +const rel = (value: string) => RelativeFilePath.fromGit(value); + describe('GitService', () => { let root: string; let git: GitService; @@ -32,7 +35,7 @@ describe('GitService', () => { repo, entries.map((entry) => entry.oid) ); - return new Map(entries.map((entry) => [entry.path, text(blobs.get(entry.oid))])); + return new Map(entries.map((entry) => [entry.path.toString(), text(blobs.get(entry.oid))])); }; beforeEach(() => { @@ -74,7 +77,7 @@ describe('GitService', () => { write(sdk, 'dist/generated.js', 'generated\n'); write(sdk, 'debug.log', 'noise\n'); - const files = await filesOf(repo, await git.treeOf(repo, sdk, ['dist/generated.js', 'missing.txt'])); + const files = await filesOf(repo, await git.treeOf(repo, sdk, ['dist/generated.js', 'missing.txt'].map(rel))); expect([...files.keys()]).to.have.members(['.gitignore', 'src/index.ts', 'dist/generated.js']); }); @@ -140,7 +143,7 @@ describe('GitService', () => { const paired = await git.diffTrees(repo, from, to, true); const plain = await git.diffTrees(repo, from, to, false); - expect(paired.map(({ status, path: changed, from: origin }) => [status, changed, origin])).to.deep.equal( + expect(paired.map(({ status, path: changed, from: origin }) => [status, changed.toString(), origin?.toString()])).to.deep.equal( [ ['M', 'b.txt', undefined], ['C', 'c.txt', undefined], @@ -150,12 +153,12 @@ describe('GitService', () => { .concat([['A', 'c.txt', undefined]]) .sort((x, y) => String(x[1]).localeCompare(String(y[1]))) ); - expect(plain.map(({ status, path: changed }) => [status, changed])).to.deep.equal([ + expect(plain.map(({ status, path: changed }) => [status, changed.toString()])).to.deep.equal([ ['D', 'a.txt'], ['M', 'b.txt'], ['A', 'c.txt'], ['A', 'moved/a.txt'] ]); - expect(plain.find((change) => change.path === 'b.txt')?.blob).to.have.lengthOf(40); + expect(plain.find((change) => change.path.toString() === 'b.txt')?.blob).to.have.lengthOf(40); }); }); diff --git a/test/infrastructure/merge-service.test.ts b/test/infrastructure/merge-service.test.ts index 803472524..d2d089288 100644 --- a/test/infrastructure/merge-service.test.ts +++ b/test/infrastructure/merge-service.test.ts @@ -1,3 +1,4 @@ +import { RelativeFilePath } from '../../src/types/file/relativeFilePath.js'; import fs from 'fs'; import os from 'os'; import path from 'path'; @@ -61,6 +62,8 @@ type Run = { const counts = (plan: MergePlan) => Object.fromEntries(Object.entries(plan.files).map(([kind, paths]) => [kind, paths.length])); +const rel = (value: string) => RelativeFilePath.fromGit(value); + describe('MergeService', () => { let root: string; let counter = 0; @@ -190,7 +193,7 @@ describe('MergeService', () => { '' ].join('\n') ); - expect(plan.conflicts).to.deep.equal([{ path: 'book.ts', kind: 'bothChanged' }]); + expect(plan.conflicts).to.deep.equal([{ path: rel('book.ts'), kind: 'bothChanged' }]); }); it('leaves a file you deleted deleted while the generator does not change it', async () => { @@ -213,7 +216,7 @@ describe('MergeService', () => { ); expect(read('a.ts')).to.equal('v2\n'); - expect(plan.conflicts).to.deep.equal([{ path: 'a.ts', kind: 'deletedByYou' }]); + expect(plan.conflicts).to.deep.equal([{ path: rel('a.ts'), kind: 'deletedByYou' }]); }); it('removes a file the generator stopped producing when you never edited it', async () => { @@ -224,7 +227,7 @@ describe('MergeService', () => { ); expect(exists('authors.ts')).to.be.false; - expect(plan.deletes).to.deep.equal(['authors.ts']); + expect(plan.deletes).to.deep.equal(['authors.ts'].map(rel)); expect(plan.files.removed).to.have.length(1); }); @@ -236,7 +239,7 @@ describe('MergeService', () => { ); expect(read('authors.ts')).to.equal('v1 + getMany\n'); - expect(plan.conflicts).to.deep.equal([{ path: 'authors.ts', kind: 'noLongerGenerated' }]); + expect(plan.conflicts).to.deep.equal([{ path: rel('authors.ts'), kind: 'noLongerGenerated' }]); }); }); @@ -251,7 +254,7 @@ describe('MergeService', () => { expect(read('delete-book.ts')).to.equal('new\n'); expect(read('custom/format.ts')).to.equal('mine\n'); expect(plan.files.added).to.have.length(1); - expect(plan.writes.map((write) => write.path)).to.deep.equal(['delete-book.ts']); + expect(plan.writes.map((write) => write.path.toString())).to.deep.equal(['delete-book.ts']); }); it('marks a path you and the generator both added with different content, and accepts one with the same', async () => { @@ -269,7 +272,7 @@ describe('MergeService', () => { expect(differ.read('retry.ts')).to.equal( ['<<<<<<< Your changes', 'yours', '=======', 'generated', '>>>>>>> Generated by APIMatic', ''].join('\n') ); - expect(differ.plan.conflicts).to.deep.equal([{ path: 'retry.ts', kind: 'bothAdded' }]); + expect(differ.plan.conflicts).to.deep.equal([{ path: rel('retry.ts'), kind: 'bothAdded' }]); expect(same.plan.writes).to.be.empty; expect(same.plan.hasConflicts()).to.be.false; }); @@ -285,7 +288,7 @@ describe('MergeService', () => { expect(read('models/catalog/book.ts')).to.equal(MODEL_V2 + HELPER); expect(exists('models/book.ts')).to.be.false; - expect(plan.renames).to.deep.equal([{ from: 'models/book.ts', to: 'models/catalog/book.ts', by: 'you' }]); + expect(plan.renames).to.deep.equal([{ from: rel('models/book.ts'), to: rel('models/catalog/book.ts'), by: 'you' }]); expect(plan.hasConflicts()).to.be.false; }); @@ -298,7 +301,7 @@ describe('MergeService', () => { expect(read('models/book-model.ts')).to.equal(MODEL_V2 + HELPER); expect(exists('models/book.ts')).to.be.false; - expect(plan.renames).to.deep.equal([{ from: 'models/book.ts', to: 'models/book-model.ts', by: 'generator' }]); + expect(plan.renames).to.deep.equal([{ from: rel('models/book.ts'), to: rel('models/book-model.ts'), by: 'generator' }]); expect(plan.hasConflicts()).to.be.false; }); }); @@ -327,7 +330,7 @@ describe('MergeService', () => { expect(replaced.bytes('logo.png')).to.deep.equal(changed); expect(replaced.plan.files.updated).to.have.length(1); expect(both.bytes('logo.png')).to.deep.equal(edited); - expect(both.plan.conflicts).to.deep.equal([{ path: 'logo.png', kind: 'binary' }]); + expect(both.plan.conflicts).to.deep.equal([{ path: rel('logo.png'), kind: 'binary' }]); }); }); @@ -363,7 +366,7 @@ describe('MergeService', () => { 'src/sdk/authors.ts' ]); expect(read('src/models/book.ts')).to.equal(MODEL_V2 + HELPER); - expect(plan.conflicts).to.deep.equal([{ path: 'docs/models/book.md', kind: 'deletedByYou' }]); + expect(plan.conflicts).to.deep.equal([{ path: rel('docs/models/book.md'), kind: 'deletedByYou' }]); expect(counts(plan)).to.deep.equal({ added: 1, updated: 0, merged: 1, removed: 0, kept: 0, unchanged: 1 }); }); @@ -382,7 +385,7 @@ describe('MergeService', () => { expect(exists('src/sdk/authors.ts')).to.be.true; expect(read('src/models/book.ts')).to.equal(MODEL_V2); - expect(plan.conflicts).to.deep.equal([{ path: 'src/sdk/authors.ts', kind: 'noLongerGenerated' }]); + expect(plan.conflicts).to.deep.equal([{ path: rel('src/sdk/authors.ts'), kind: 'noLongerGenerated' }]); expect(plan.files.updated).to.have.length(2); }); }); diff --git a/test/infrastructure/pristine-store-service.test.ts b/test/infrastructure/pristine-store-service.test.ts index 931ed3f33..75e9c197b 100644 --- a/test/infrastructure/pristine-store-service.test.ts +++ b/test/infrastructure/pristine-store-service.test.ts @@ -1,3 +1,4 @@ +import { RelativeFilePath } from '../../src/types/file/relativeFilePath.js'; import fs from 'fs'; import os from 'os'; import path from 'path'; @@ -18,6 +19,8 @@ const TYPESCRIPT: SdkGeneration = { stability: Stability.STABLE }; +const rel = (value: string) => RelativeFilePath.fromGit(value); + describe('PristineStoreService', () => { let root: string; let service: PristineStoreService; @@ -246,14 +249,14 @@ describe('PristineStoreService', () => { const pristine = (await record(repository, { 'README.md': '# v1' }))._unsafeUnwrap(); const written = GenerationRecord.written(TYPESCRIPT, pristine, new Date('2026-10-20T10:00:00Z'), '2.3.0', [ 'src/index.ts' - ]); + ].map(rel)); const file = (await service.writeRecord(sdk, written))._unsafeUnwrap(); expect(file.toString()).to.equal(path.join(sdk.toString(), '.apimatic', 'generation.json')); const read = (await service.readRecord(sdk))!; expect(read.pristineCommit().isEqual(pristine)).to.be.true; - expect(read.conflictedPaths()).to.deep.equal(['src/index.ts']); + expect(read.conflictedPaths().map(String)).to.deep.equal(['src/index.ts']); expect(read.isFor(TYPESCRIPT)).to.be.true; expect(read.toJson()).to.equal(written.toJson()); }); @@ -270,7 +273,7 @@ describe('PristineStoreService', () => { const tree = (await service.snapshotGenerated(repository, generated({ 'README.md': '# v1' })))._unsafeUnwrap(); const recorded = ( - await service.recordGeneration(repository, tree, TYPESCRIPT, '2.3.0', ['src/index.ts']) + await service.recordGeneration(repository, tree, TYPESCRIPT, '2.3.0', ['src/index.ts'].map(rel)) )._unsafeUnwrap(); const read = (await service.readRecord(repository.sdkDirectory()))!; @@ -331,7 +334,7 @@ describe('PristineStoreService', () => { const pristine = (await record(repository, { 'README.md': '# v1' }))._unsafeUnwrap(); await service.writeRecord( sdk, - GenerationRecord.written(TYPESCRIPT, pristine, new Date(), '2.3.0', ['src/a.ts', 'src/b.ts', 'src/gone.ts']) + GenerationRecord.written(TYPESCRIPT, pristine, new Date(), '2.3.0', ['src/a.ts', 'src/b.ts', 'src/gone.ts'].map(rel)) ); write(sdk, 'src/a.ts', MARKED); write(sdk, 'src/b.ts', 'resolved\n'); @@ -339,14 +342,14 @@ describe('PristineStoreService', () => { const state = (await service.inspect(sdk, TYPESCRIPT))._unsafeUnwrap(); expect(state).to.include({ kind: 'unresolved' }); - expect(state.kind === 'unresolved' && state.paths).to.deep.equal(['src/a.ts']); + expect(state.kind === 'unresolved' && state.paths.map(String)).to.deep.equal(['src/a.ts']); }); it('is ready to merge against the recorded snapshot once the markers are gone', async () => { const repository = await project(); const sdk = repository.sdkDirectory(); const pristine = (await record(repository, { 'README.md': '# v1' }))._unsafeUnwrap(); - await service.writeRecord(sdk, GenerationRecord.written(TYPESCRIPT, pristine, new Date(), '2.3.0', ['src/a.ts'])); + await service.writeRecord(sdk, GenerationRecord.written(TYPESCRIPT, pristine, new Date(), '2.3.0', ['src/a.ts'].map(rel))); write(sdk, 'src/a.ts', 'resolved\n'); const state = (await service.inspect(sdk, TYPESCRIPT))._unsafeUnwrap(); diff --git a/test/prompts/sdk/write.test.ts b/test/prompts/sdk/write.test.ts index f420522de..97972c2ef 100644 --- a/test/prompts/sdk/write.test.ts +++ b/test/prompts/sdk/write.test.ts @@ -1,23 +1,34 @@ -import path from 'node:path'; import { stripVTControlCharacters } from 'node:util'; import { expect } from 'chai'; import sinon from 'sinon'; import { log } from '@clack/prompts'; -import { MergePlan } from '../../../src/application/sdk-merge/merge-plan.js'; +import { ConflictKind, MergePlan } from '../../../src/application/sdk-merge/merge-plan.js'; import { SdkWritePrompts } from '../../../src/prompts/sdk/write.js'; import { DirectoryPath } from '../../../src/types/file/directoryPath.js'; +import { RelativeFilePath } from '../../../src/types/file/relativeFilePath.js'; import { SdkRepository } from '../../../src/types/sdk/sdk-repository.js'; -const plan = (files: Partial, conflicts: MergePlan['conflicts'] = []) => - new MergePlan([], [], conflicts, [], { - added: [], - updated: [], - merged: [], - removed: [], - kept: [], - unchanged: [], - ...files - }); +const rel = (value: string) => RelativeFilePath.fromGit(value); + +const plan = ( + files: Partial>, + conflicts: { path: string; kind: ConflictKind }[] = [] +) => + new MergePlan( + [], + [], + conflicts.map((conflict) => ({ ...conflict, path: rel(conflict.path) })), + [], + { + added: [], + updated: [], + merged: [], + removed: [], + kept: [], + unchanged: [], + ...Object.fromEntries(Object.entries(files).map(([kind, paths]) => [kind, paths.map(rel)])) + } + ); describe('SdkWritePrompts.sdkMerged', () => { const root = new DirectoryPath(process.cwd()); diff --git a/test/types/file/relativeFilePath.test.ts b/test/types/file/relativeFilePath.test.ts new file mode 100644 index 000000000..ab1c3ee2a --- /dev/null +++ b/test/types/file/relativeFilePath.test.ts @@ -0,0 +1,48 @@ +import path from 'node:path'; +import { expect } from 'chai'; +import { DirectoryPath } from '../../../src/types/file/directoryPath.js'; +import { RelativeFilePath } from '../../../src/types/file/relativeFilePath.js'; + +describe('RelativeFilePath', () => { + it('accepts a path as git names it inside a tree', () => { + expect(RelativeFilePath.create('src/models/book.ts')?.toString()).to.equal('src/models/book.ts'); + expect(RelativeFilePath.create('README.md')?.toString()).to.equal('README.md'); + }); + + for (const value of ['', '/src/a.ts', 'src\\a.ts', 'src//a.ts', './a.ts', '../a.ts', 'src/../a.ts', 'C:/a.ts']) { + it(`refuses ${JSON.stringify(value)}`, () => { + expect(RelativeFilePath.create(value)).to.be.undefined; + }); + } + + it('is the file on disk below a directory, spelt the way the OS spells it', () => { + const sdk = new DirectoryPath(path.join('sdk', 'typescript')); + + const file = RelativeFilePath.fromGit('src/models/book.ts').in(sdk); + + expect(file.toString()).to.equal(path.resolve('sdk', 'typescript', 'src', 'models', 'book.ts')); + expect(file.name().toString()).to.equal('book.ts'); + }); + + it('is seen from above a prefix without changing its spelling, and unchanged from an empty one', () => { + const file = RelativeFilePath.fromGit('src/models/book.ts'); + + expect(file.below('sdk/typescript').toString()).to.equal('sdk/typescript/src/models/book.ts'); + expect(file.below('')).to.equal(file); + }); + + it('compares by value', () => { + const a = RelativeFilePath.fromGit('a.ts'); + + expect(a.isEqual(RelativeFilePath.fromGit('a.ts'))).to.be.true; + expect(a.isEqual(RelativeFilePath.fromGit('b.ts'))).to.be.false; + expect([RelativeFilePath.fromGit('b.ts'), a].sort((x, y) => x.compare(y)).map(String)).to.deep.equal([ + 'a.ts', + 'b.ts' + ]); + }); + + it('throws on what git would never print, so a parser bug is found rather than carried', () => { + expect(() => RelativeFilePath.fromGit('/absolute')).to.throw('not relative'); + }); +}); diff --git a/test/types/sdk/generation-record.test.ts b/test/types/sdk/generation-record.test.ts index 8525d5fc3..5f2f74cf1 100644 --- a/test/types/sdk/generation-record.test.ts +++ b/test/types/sdk/generation-record.test.ts @@ -1,3 +1,4 @@ +import { RelativeFilePath } from '../../../src/types/file/relativeFilePath.js'; import { expect } from 'chai'; import { CommitId } from '../../../src/types/sdk/commit-id.js'; import { CodeGenerationVersion, Language, Stability } from '../../../src/types/sdk/generate.js'; @@ -18,6 +19,8 @@ const json = (overrides: Record = {}) => ...overrides }); +const rel = (value: string) => RelativeFilePath.fromGit(value); + describe('GenerationRecord', () => { it('round-trips through its file form', () => { const record = GenerationRecord.written( @@ -25,7 +28,7 @@ describe('GenerationRecord', () => { CommitId.create(SHA)!, new Date('2026-10-20T10:00:00Z'), '2.3.0', - ['src/models/book.py'] + [rel('src/models/book.py')] ); const parsed = GenerationRecord.parse(record.toJson())!; From a11166f110781e25b719944b0d7856e42a08f078 Mon Sep 17 00:00:00 2001 From: Asad Ali <14asadali@gmail.com> Date: Wed, 21 Oct 2026 09:46:52 +0500 Subject: [PATCH 09/29] refactor(sdk): fold the merge service into the pristine store The merge, its application to disk and the index staging work on the same repository, base and trees the snapshots do, so they are methods of the store; the write action holds one service. Co-Authored-By: Claude Fable 5.1 --- .ai/plans/custom-code.md | 7 +- src/actions/sdk/write.ts | 8 +- src/infrastructure/merge-service.ts | 86 ------------------- src/infrastructure/pristine-store-service.ts | 81 ++++++++++++++++- test/actions/sdk/write.test.ts | 5 +- ...e.test.ts => pristine-store-merge.test.ts} | 15 ++-- 6 files changed, 96 insertions(+), 106 deletions(-) delete mode 100644 src/infrastructure/merge-service.ts rename test/infrastructure/{merge-service.test.ts => pristine-store-merge.test.ts} (97%) diff --git a/.ai/plans/custom-code.md b/.ai/plans/custom-code.md index 8197bc115..868c024a2 100644 --- a/.ai/plans/custom-code.md +++ b/.ai/plans/custom-code.md @@ -275,7 +275,7 @@ The eleven Speakeasy runs from the lab notebook, plus three the lab did not cove | 14 | Fresh clone, regenerate | fails unless the hidden refs were pushed | merges if the team pushed and fetched the refs; otherwise a clear message with the commands and the overwrite question | Every row of section 5 and this table is a test over real repositories in -`test/infrastructure/merge-service.test.ts`, `test/actions/sdk/prepare.test.ts` (row 14 with +`test/infrastructure/pristine-store-merge.test.ts`, `test/actions/sdk/prepare.test.ts` (row 14 with a bare remote) and `test/actions/sdk/write.test.ts`. ## 8. Phases, as delivered @@ -283,7 +283,7 @@ a bare remote) and `test/actions/sdk/write.test.ts`. | Phase | Commit | What | |---|---|---| | 1 | `db3fe9f2`, reworked in `185e0663` | `GitService` (the system git through `execa`, every call with the 3.3 settings) and `PristineStoreService` with the value objects: repository, refs, commit and tree ids, git version, generation record. | -| 2 | `185e0663` | `MergeService`: the temporary commits, `merge-tree --write-tree -z`, the diffs; `src/application/sdk-merge/` keeps only what is pure: parsing merge-tree's output, the conflict kinds, marker relabelling, `MergePlan`. | +| 2 | `185e0663` | The merge (since folded into `PristineStoreService`): the temporary commits, `merge-tree --write-tree -z`, the diffs; `src/application/sdk-merge/` keeps only what is pure: parsing merge-tree's output, the conflict kinds, marker relabelling, `MergePlan`. | | 3 | `4d5b9ccf` | `SdkPrepareAction`, `SdkDestination`, `SdkWriteAction`, their prompts, `PristineStoreService.inspect`; `GenerateAction` back to validation and generation; `SdkContext.save` keeps `.git`. | | 4, 5 | `87453917` | The missing-snapshot message with the push and fetch commands; `stageConflicts` through `update-index --index-info`; `SdkMergeConflictedEvent`. | | 6 | this file | `CONTEXT.md` terms, the README section and requirement, the command description. | @@ -295,8 +295,7 @@ PR into `dev`, squash-merged, title `feat(sdk): keep your edits when regeneratin | File | Role | |---|---| | `src/infrastructure/git-service.ts` | the system git behind every snapshot, the merge and the index staging | -| `src/infrastructure/pristine-store-service.ts` | snapshots, the record, `inspect` | -| `src/infrastructure/merge-service.ts` | the merge, its application to disk, the staging | +| `src/infrastructure/pristine-store-service.ts` | snapshots, the record, `inspect`, the merge, its application to disk, the staging | | `src/application/sdk-merge/*.ts` | pure: merge-tree output, conflict kinds, markers, `MergePlan` | | `src/types/sdk/{sdk-repository,snapshot-ref,commit-id,tree-id,git-version,generation-record,sdk-destination,snapshot-files}.ts` | value objects | | `src/actions/sdk/{generate,prepare,write}.ts`, `src/prompts/sdk/{generate,prepare,write}.ts` | the run | diff --git a/src/actions/sdk/write.ts b/src/actions/sdk/write.ts index 386d9fd13..1191c57b5 100644 --- a/src/actions/sdk/write.ts +++ b/src/actions/sdk/write.ts @@ -1,7 +1,6 @@ import { err, ok, Result } from 'neverthrow'; import { MergePlan } from '../../application/sdk-merge/merge-plan.js'; import { envInfo } from '../../infrastructure/env-info.js'; -import { MergeService } from '../../infrastructure/merge-service.js'; import { PristineStoreProblem, PristineStoreService } from '../../infrastructure/pristine-store-service.js'; import { SdkWritePrompts } from '../../prompts/sdk/write.js'; import { DirectoryPath } from '../../types/file/directoryPath.js'; @@ -20,7 +19,6 @@ const MERGE_NEEDS_RESOLVING = 'Resolve the conflicts and rerun'; export class SdkWriteAction { private readonly prompts = new SdkWritePrompts(); private readonly store = new PristineStoreService(); - private readonly mergeService = new MergeService(); public readonly execute = async ( destination: SdkDestination, @@ -93,12 +91,12 @@ export class SdkWriteAction { if (disk.isErr()) { return this.customCodeFailed(disk.error); } - const merged = await this.mergeService.merge(repository, base, disk.value, generated.value); + const merged = await this.store.merge(repository, base, disk.value, generated.value); if (merged.isErr()) { return this.customCodeFailed(merged.error); } const { plan } = merged.value; - const applied = await this.mergeService.apply(repository, plan); + const applied = await this.store.apply(repository, plan); if (applied.isErr()) { return this.customCodeFailed(applied.error); } @@ -107,7 +105,7 @@ export class SdkWriteAction { if (!plan.hasConflicts()) { return ActionResult.success(); } - const staged = await this.mergeService.stageConflicts(repository, merged.value); + const staged = await this.store.stageConflicts(repository, merged.value); if (staged.isErr()) { this.prompts.conflictsNotStaged(staged.error.reason); } diff --git a/src/infrastructure/merge-service.ts b/src/infrastructure/merge-service.ts deleted file mode 100644 index d5fb02c29..000000000 --- a/src/infrastructure/merge-service.ts +++ /dev/null @@ -1,86 +0,0 @@ -import fs from 'node:fs'; -import path from 'node:path'; -import { err, ok, Result } from 'neverthrow'; -import { MergePlan } from '../application/sdk-merge/merge-plan.js'; -import { relabelMarkers } from '../application/sdk-merge/markers.js'; -import { parseMergeTreeOutput, StageEntry } from '../application/sdk-merge/merge-tree-output.js'; -import { planMerge } from '../application/sdk-merge/sdk-merge.js'; -import { CommitId } from '../types/sdk/commit-id.js'; -import { SdkRepository } from '../types/sdk/sdk-repository.js'; -import { TreeId } from '../types/sdk/tree-id.js'; -import { errorMessage } from '../utils/error-utils.js'; -import { GitService } from './git-service.js'; - -export type MergeProblem = { kind: 'gitFailed'; reason: string }; - -/** The plan, the merged tree, and git's versions of every conflicted path, below the SDK directory. */ -export type MergeOutcome = { plan: MergePlan; merged: TreeId; stages: StageEntry[] }; - -/** - * The three-way merge of an SDK directory, done by git: the disk and the fresh generation are - * committed on the pristine base as temporary commits, `merge-tree` merges them, and the plan - * says which paths differ between the disk and the result. - */ -export class MergeService { - private readonly git = new GitService(); - - public async merge( - repository: SdkRepository, - base: CommitId, - disk: TreeId, - generated: TreeId - ): Promise> { - const root = repository.root(); - try { - const baseTree = await this.git.treeOfCommit(root, base); - const yours = await this.git.commitTree(root, disk, [base], 'apimatic: your changes'); - const theirs = await this.git.commitTree(root, generated, [base], 'apimatic: generated'); - const run = await this.git.mergeTree(root, yours, theirs); - const output = parseMergeTreeOutput(run.output); - const merged = TreeId.fromGit(output.tree); - const plan = planMerge({ - merge: output, - yours: await this.git.diffTrees(root, baseTree, disk, true), - generated: await this.git.diffTrees(root, baseTree, generated, true), - applied: await this.git.diffTrees(root, disk, merged, false), - basePaths: (await this.git.listTree(root, baseTree)).map((entry) => entry.path) - }); - return ok({ plan, merged, stages: output.stages }); - } catch (error) { - return err({ kind: 'gitFailed', reason: errorMessage(error) }); - } - } - - /** Writes and removes what the plan says, in the SDK directory, with the CLI's labels on any markers. */ - public async apply(repository: SdkRepository, plan: MergePlan): Promise> { - const sdk = repository.sdkDirectory(); - try { - const blobs = await this.git.readBlobs(repository.root(), [...new Set(plan.writes.map((write) => write.blob))]); - for (const write of plan.writes) { - const content = blobs.get(write.blob)!; - const target = write.path.in(sdk).toString(); - await fs.promises.mkdir(path.dirname(target), { recursive: true }); - await fs.promises.writeFile(target, plan.hasMarkers(write.path) ? relabelMarkers(content) : content); - } - for (const relative of plan.deletes) { - await fs.promises.rm(relative.in(sdk).toString(), { force: true }); - } - return ok(undefined); - } catch (error) { - return err({ kind: 'gitFailed', reason: errorMessage(error) }); - } - } - - /** Marks the conflicted paths in the repository's index the way a merge would, so git's own tools take over. */ - public async stageConflicts(repository: SdkRepository, outcome: MergeOutcome): Promise> { - try { - await this.git.stageConflicts( - repository.root(), - outcome.stages.map((entry) => ({ ...entry, path: repository.rootRelative(entry.path) })) - ); - return ok(undefined); - } catch (error) { - return err({ kind: 'gitFailed', reason: errorMessage(error) }); - } - } -} diff --git a/src/infrastructure/pristine-store-service.ts b/src/infrastructure/pristine-store-service.ts index be3250c6b..cad346b6a 100644 --- a/src/infrastructure/pristine-store-service.ts +++ b/src/infrastructure/pristine-store-service.ts @@ -1,7 +1,10 @@ import fs from 'node:fs'; import path from 'node:path'; import { err, ok, Result } from 'neverthrow'; -import { hasConflictMarkers } from '../application/sdk-merge/markers.js'; +import { hasConflictMarkers, relabelMarkers } from '../application/sdk-merge/markers.js'; +import { MergePlan } from '../application/sdk-merge/merge-plan.js'; +import { parseMergeTreeOutput, StageEntry } from '../application/sdk-merge/merge-tree-output.js'; +import { planMerge } from '../application/sdk-merge/sdk-merge.js'; import { DirectoryPath } from '../types/file/directoryPath.js'; import { FileName } from '../types/file/fileName.js'; import { FilePath } from '../types/file/filePath.js'; @@ -31,6 +34,9 @@ export type SdkDirectoryState = | { kind: 'unresolved'; repository: SdkRepository; paths: RelativeFilePath[] } | { kind: 'ready'; repository: SdkRepository; base: CommitId }; +/** The plan, the merged tree, and git's versions of every conflicted path, below the SDK directory. */ +export type MergeOutcome = { plan: MergePlan; merged: TreeId; stages: StageEntry[] }; + const RECORD_DIRECTORY = '.apimatic'; const RECORD_FILE = 'generation.json'; @@ -53,8 +59,9 @@ function realSpelling(directory: DirectoryPath): { real: DirectoryPath; existing /** * The snapshots behind custom code, kept as commits under hidden refs of the repository that * encloses an SDK directory: the pristine chain (one commit per generation, the base of the - * next merge) and the previous state (the directory before the last run wrote to it). The - * repository is never pushed, fetched, or checked out from here. + * next merge) and the previous state (the directory before the last run wrote to it), and + * the merge of a regeneration over the directory against that base. The repository is never + * pushed, fetched, or checked out from here. */ export class PristineStoreService { private readonly fileService = new FileService(); @@ -240,6 +247,74 @@ export class PristineStoreService { } } + /** + * The three-way merge, done by git: the disk and the fresh generation are committed on the + * pristine base as temporary commits, `merge-tree` merges them, and the plan says which + * paths differ between the disk and the result. + */ + public async merge( + repository: SdkRepository, + base: CommitId, + disk: TreeId, + generated: TreeId + ): Promise> { + const root = repository.root(); + try { + const baseTree = await this.git.treeOfCommit(root, base); + const yours = await this.git.commitTree(root, disk, [base], 'apimatic: your changes'); + const theirs = await this.git.commitTree(root, generated, [base], 'apimatic: generated'); + const run = await this.git.mergeTree(root, yours, theirs); + const output = parseMergeTreeOutput(run.output); + const merged = TreeId.fromGit(output.tree); + const plan = planMerge({ + merge: output, + yours: await this.git.diffTrees(root, baseTree, disk, true), + generated: await this.git.diffTrees(root, baseTree, generated, true), + applied: await this.git.diffTrees(root, disk, merged, false), + basePaths: (await this.git.listTree(root, baseTree)).map((entry) => entry.path) + }); + return ok({ plan, merged, stages: output.stages }); + } catch (error) { + return err(gitFailed(errorMessage(error))); + } + } + + /** Writes and removes what the plan says, in the SDK directory, with the CLI's labels on any markers. */ + public async apply(repository: SdkRepository, plan: MergePlan): Promise> { + const sdk = repository.sdkDirectory(); + try { + const blobs = await this.git.readBlobs(repository.root(), [...new Set(plan.writes.map((write) => write.blob))]); + for (const write of plan.writes) { + const content = blobs.get(write.blob)!; + const target = write.path.in(sdk).toString(); + await fs.promises.mkdir(path.dirname(target), { recursive: true }); + await fs.promises.writeFile(target, plan.hasMarkers(write.path) ? relabelMarkers(content) : content); + } + for (const relative of plan.deletes) { + await fs.promises.rm(relative.in(sdk).toString(), { force: true }); + } + return ok(undefined); + } catch (error) { + return err(gitFailed(errorMessage(error))); + } + } + + /** Marks the conflicted paths in the repository's index the way a merge would, so git's own tools take over. */ + public async stageConflicts( + repository: SdkRepository, + outcome: MergeOutcome + ): Promise> { + try { + await this.git.stageConflicts( + repository.root(), + outcome.stages.map((entry) => ({ ...entry, path: repository.rootRelative(entry.path) })) + ); + return ok(undefined); + } catch (error) { + return err(gitFailed(errorMessage(error))); + } + } + // The ref name is the SDK directory's path, so one SDK directory inside another cannot have its own. private async verifyNotNested(repository: SdkRepository): Promise> { const ref = repository.pristineRef(); diff --git a/test/actions/sdk/write.test.ts b/test/actions/sdk/write.test.ts index c71e8658e..6f38b7fb9 100644 --- a/test/actions/sdk/write.test.ts +++ b/test/actions/sdk/write.test.ts @@ -10,7 +10,6 @@ import sinon from 'sinon'; import { SdkWriteAction } from '../../../src/actions/sdk/write.js'; import { GENERATED_MARKER, YOURS_MARKER } from '../../../src/application/sdk-merge/markers.js'; import { GitService } from '../../../src/infrastructure/git-service.js'; -import { MergeService } from '../../../src/infrastructure/merge-service.js'; import { PristineStoreService } from '../../../src/infrastructure/pristine-store-service.js'; import { ZipService } from '../../../src/infrastructure/zip-service.js'; import { SdkWritePrompts } from '../../../src/prompts/sdk/write.js'; @@ -234,7 +233,9 @@ describe('SdkWriteAction', () => { it('still reports the conflicts when they cannot be marked in the index, with a warning', async () => { const { repository, base } = await recorded({ 'README.md': '# sdk', 'models/book.py': MODEL_V1 }); write(sdk(), { 'models/book.py': MODEL_V1_EDITED }); - sinon.stub(MergeService.prototype, 'stageConflicts').resolves(err({ kind: 'gitFailed', reason: 'index locked' })); + sinon + .stub(PristineStoreService.prototype, 'stageConflicts') + .resolves(err({ kind: 'gitFailed', reason: 'index locked' })); const conflictsNotStaged = sinon.stub(SdkWritePrompts.prototype, 'conflictsNotStaged'); const conflicts = sinon.stub(SdkWritePrompts.prototype, 'conflicts'); diff --git a/test/infrastructure/merge-service.test.ts b/test/infrastructure/pristine-store-merge.test.ts similarity index 97% rename from test/infrastructure/merge-service.test.ts rename to test/infrastructure/pristine-store-merge.test.ts index d2d089288..713da9dbe 100644 --- a/test/infrastructure/merge-service.test.ts +++ b/test/infrastructure/pristine-store-merge.test.ts @@ -6,7 +6,6 @@ import { Buffer } from 'node:buffer'; import { expect } from 'chai'; import { MergePlan } from '../../src/application/sdk-merge/merge-plan.js'; import { GitService } from '../../src/infrastructure/git-service.js'; -import { MergeService } from '../../src/infrastructure/merge-service.js'; import { PristineStoreService } from '../../src/infrastructure/pristine-store-service.js'; import { DirectoryPath } from '../../src/types/file/directoryPath.js'; import { CodeGenerationVersion, Language, Stability } from '../../src/types/sdk/generate.js'; @@ -64,12 +63,12 @@ const counts = (plan: MergePlan) => const rel = (value: string) => RelativeFilePath.fromGit(value); -describe('MergeService', () => { +describe('PristineStoreService: the merge', () => { let root: string; let counter = 0; const git = new GitService(); const store = new PristineStoreService(); - const service = new MergeService(); + const service = store; const write = (directory: DirectoryPath, files: Files) => { for (const [relative, contents] of Object.entries(files)) { @@ -127,7 +126,7 @@ describe('MergeService', () => { }; beforeEach(() => { - root = fs.mkdtempSync(path.join(os.tmpdir(), 'merge-service-')); + root = fs.mkdtempSync(path.join(os.tmpdir(), 'pristine-store-merge-')); }); afterEach(() => { @@ -288,7 +287,9 @@ describe('MergeService', () => { expect(read('models/catalog/book.ts')).to.equal(MODEL_V2 + HELPER); expect(exists('models/book.ts')).to.be.false; - expect(plan.renames).to.deep.equal([{ from: rel('models/book.ts'), to: rel('models/catalog/book.ts'), by: 'you' }]); + expect(plan.renames).to.deep.equal([ + { from: rel('models/book.ts'), to: rel('models/catalog/book.ts'), by: 'you' } + ]); expect(plan.hasConflicts()).to.be.false; }); @@ -301,7 +302,9 @@ describe('MergeService', () => { expect(read('models/book-model.ts')).to.equal(MODEL_V2 + HELPER); expect(exists('models/book.ts')).to.be.false; - expect(plan.renames).to.deep.equal([{ from: rel('models/book.ts'), to: rel('models/book-model.ts'), by: 'generator' }]); + expect(plan.renames).to.deep.equal([ + { from: rel('models/book.ts'), to: rel('models/book-model.ts'), by: 'generator' } + ]); expect(plan.hasConflicts()).to.be.false; }); }); From d39ea5016ffd222a2b0d48721d99ca60ac04c44c Mon Sep 17 00:00:00 2001 From: Asad Ali <14asadali@gmail.com> Date: Wed, 21 Oct 2026 10:10:09 +0500 Subject: [PATCH 10/29] refactor(sdk): make the repository a context The methods of the pristine store all took the repository as their first argument and read none of the store's own state, so they are the repository's: SdkRepositoryContext holds the git and file services the way SdkContext does, and SdkContext hands one back (found or initialised). The store and the SdkRepository value object are gone; the two tests that read snapshots use a small helper over GitService. Co-Authored-By: Claude Fable 5.1 --- .ai/plans/custom-code.md | 16 +- src/actions/sdk/prepare.ts | 29 +- src/actions/sdk/write.ts | 30 +- src/infrastructure/pristine-store-service.ts | 358 ------------------ src/prompts/sdk/prepare.ts | 6 +- src/prompts/sdk/write.ts | 8 +- src/types/sdk-context.ts | 50 ++- src/types/sdk-repository-context.ts | 309 +++++++++++++++ src/types/sdk/sdk-destination.ts | 6 +- src/types/sdk/sdk-repository.ts | 49 --- src/types/sdk/snapshot-files.ts | 2 - test/actions/sdk/prepare.test.ts | 21 +- test/actions/sdk/write.test.ts | 30 +- test/helpers/snapshots.ts | 13 + test/prompts/sdk/write.test.ts | 4 +- .../sdk-repository-context-merge.test.ts} | 24 +- .../sdk-repository-context.test.ts} | 179 ++++----- 17 files changed, 524 insertions(+), 610 deletions(-) delete mode 100644 src/infrastructure/pristine-store-service.ts create mode 100644 src/types/sdk-repository-context.ts delete mode 100644 src/types/sdk/sdk-repository.ts delete mode 100644 src/types/sdk/snapshot-files.ts create mode 100644 test/helpers/snapshots.ts rename test/{infrastructure/pristine-store-merge.test.ts => types/sdk-repository-context-merge.test.ts} (94%) rename test/{infrastructure/pristine-store-service.test.ts => types/sdk-repository-context.test.ts} (65%) diff --git a/.ai/plans/custom-code.md b/.ai/plans/custom-code.md index 868c024a2..4f338f44c 100644 --- a/.ai/plans/custom-code.md +++ b/.ai/plans/custom-code.md @@ -161,7 +161,7 @@ directory. `SdkPrepareAction` runs before the upload and returns an `SdkDestinat 1. `--zip` takes today's path entirely: `overwrite`. 2. `git --version`. Absent, or older than 2.38: warn that customizations will not persist; `overwrite`, writing no `.git` and no `.apimatic/`. -3. `PristineStoreService.inspect` gathers the facts: the enclosing repository (from the +3. `SdkContext.repository()` finds the enclosing repository (from the nearest existing ancestor, since a first run's directory is not there yet), the nesting check, the record, whether its commit is present, and which listed files still hold markers. A nested SDK directory is refused here. @@ -275,16 +275,16 @@ The eleven Speakeasy runs from the lab notebook, plus three the lab did not cove | 14 | Fresh clone, regenerate | fails unless the hidden refs were pushed | merges if the team pushed and fetched the refs; otherwise a clear message with the commands and the overwrite question | Every row of section 5 and this table is a test over real repositories in -`test/infrastructure/pristine-store-merge.test.ts`, `test/actions/sdk/prepare.test.ts` (row 14 with +`test/types/sdk-repository-context-merge.test.ts`, `test/actions/sdk/prepare.test.ts` (row 14 with a bare remote) and `test/actions/sdk/write.test.ts`. ## 8. Phases, as delivered | Phase | Commit | What | |---|---|---| -| 1 | `db3fe9f2`, reworked in `185e0663` | `GitService` (the system git through `execa`, every call with the 3.3 settings) and `PristineStoreService` with the value objects: repository, refs, commit and tree ids, git version, generation record. | -| 2 | `185e0663` | The merge (since folded into `PristineStoreService`): the temporary commits, `merge-tree --write-tree -z`, the diffs; `src/application/sdk-merge/` keeps only what is pure: parsing merge-tree's output, the conflict kinds, marker relabelling, `MergePlan`. | -| 3 | `4d5b9ccf` | `SdkPrepareAction`, `SdkDestination`, `SdkWriteAction`, their prompts, `PristineStoreService.inspect`; `GenerateAction` back to validation and generation; `SdkContext.save` keeps `.git`. | +| 1 | `db3fe9f2`, reworked in `185e0663` | `GitService` (the system git through `execa`, every call with the 3.3 settings) and the pristine store (since the `SdkRepositoryContext`) with the value objects: refs, commit and tree ids, git version, generation record. | +| 2 | `185e0663` | The merge (since moved onto `SdkRepositoryContext`): the temporary commits, `merge-tree --write-tree -z`, the diffs; `src/application/sdk-merge/` keeps only what is pure: parsing merge-tree's output, the conflict kinds, marker relabelling, `MergePlan`. | +| 3 | `4d5b9ccf` | `SdkPrepareAction`, `SdkDestination`, `SdkWriteAction`, their prompts, the repository's `inspect`; `GenerateAction` back to validation and generation; `SdkContext.save` keeps `.git`. | | 4, 5 | `87453917` | The missing-snapshot message with the push and fetch commands; `stageConflicts` through `update-index --index-info`; `SdkMergeConflictedEvent`. | | 6 | this file | `CONTEXT.md` terms, the README section and requirement, the command description. | @@ -295,11 +295,11 @@ PR into `dev`, squash-merged, title `feat(sdk): keep your edits when regeneratin | File | Role | |---|---| | `src/infrastructure/git-service.ts` | the system git behind every snapshot, the merge and the index staging | -| `src/infrastructure/pristine-store-service.ts` | snapshots, the record, `inspect`, the merge, its application to disk, the staging | +| `src/types/sdk-repository-context.ts` | the SDK directory with its repository: snapshots, the record, `inspect`, the merge, its application to disk, the staging | | `src/application/sdk-merge/*.ts` | pure: merge-tree output, conflict kinds, markers, `MergePlan` | -| `src/types/sdk/{sdk-repository,snapshot-ref,commit-id,tree-id,git-version,generation-record,sdk-destination,snapshot-files}.ts` | value objects | +| `src/types/sdk/{snapshot-ref,commit-id,tree-id,git-version,generation-record,sdk-destination}.ts`, `src/types/file/relativeFilePath.ts` | value objects | | `src/actions/sdk/{generate,prepare,write}.ts`, `src/prompts/sdk/{generate,prepare,write}.ts` | the run | -| `src/types/sdk-context.ts` | `directory()`, save keeps `.git` | +| `src/types/sdk-context.ts` | `directory()`, `repository()`, `initRepository()`, save keeps `.git` | | `src/types/events/sdk-merge-conflicted.ts`, `src/commands/sdk/generate.ts` | the conflict event | | `test/…` | mirrors each file above | diff --git a/src/actions/sdk/prepare.ts b/src/actions/sdk/prepare.ts index 6f728c18d..99051f9c0 100644 --- a/src/actions/sdk/prepare.ts +++ b/src/actions/sdk/prepare.ts @@ -1,5 +1,4 @@ import { GitService } from '../../infrastructure/git-service.js'; -import { PristineStoreService } from '../../infrastructure/pristine-store-service.js'; import { SdkPreparePrompts } from '../../prompts/sdk/prepare.js'; import { SdkContext } from '../../types/sdk-context.js'; import { SdkGeneration } from '../../types/sdk/generation-record.js'; @@ -13,7 +12,6 @@ import { ActionResult } from '../action-result.js'; export class SdkPrepareAction { private readonly prompts = new SdkPreparePrompts(); private readonly git = new GitService(); - private readonly store = new PristineStoreService(); public readonly execute = async ( sdkContext: SdkContext, @@ -51,8 +49,11 @@ export class SdkPrepareAction { return ActionResult.success({ kind: 'overwrite', zip: false }); } - const sdk = sdkContext.directory(); - const state = await this.store.inspect(sdk, generation); + const repository = await sdkContext.repository(); + if (repository === undefined) { + return ActionResult.success({ kind: 'record', repository }); + } + const state = await repository.inspect(generation); if (state.isErr()) { if (state.error.kind === 'nestedSdkDirectory') { this.prompts.nestedSdkDirectory(state.error.reason); @@ -62,25 +63,19 @@ export class SdkPrepareAction { return ActionResult.failed(); } switch (state.value.kind) { - case 'noRepository': - return ActionResult.success({ kind: 'record', repository: undefined }); case 'noRecord': - return ActionResult.success({ kind: 'record', repository: state.value.repository }); + return ActionResult.success({ kind: 'record', repository }); case 'otherGeneration': - this.prompts.recordNotForThisGeneration(state.value.recorded, generation, sdk); - return ActionResult.success({ kind: 'record', repository: state.value.repository }); + this.prompts.recordNotForThisGeneration(state.value.recorded, generation, sdkContext.directory()); + return ActionResult.success({ kind: 'record', repository }); case 'snapshotMissing': - this.prompts.snapshotMissing(state.value.commit, state.value.repository); - return ActionResult.success({ kind: 'record', repository: state.value.repository }); + this.prompts.snapshotMissing(state.value.commit, repository); + return ActionResult.success({ kind: 'record', repository }); case 'unresolved': - this.prompts.conflictsUnresolved(state.value.paths, state.value.repository); + this.prompts.conflictsUnresolved(state.value.paths, repository); return ActionResult.failed(); case 'ready': - return ActionResult.success({ - kind: 'merge', - repository: state.value.repository, - base: state.value.base - }); + return ActionResult.success({ kind: 'merge', repository, base: state.value.base }); } } } diff --git a/src/actions/sdk/write.ts b/src/actions/sdk/write.ts index 1191c57b5..979ba851b 100644 --- a/src/actions/sdk/write.ts +++ b/src/actions/sdk/write.ts @@ -1,15 +1,14 @@ import { err, ok, Result } from 'neverthrow'; import { MergePlan } from '../../application/sdk-merge/merge-plan.js'; import { envInfo } from '../../infrastructure/env-info.js'; -import { PristineStoreProblem, PristineStoreService } from '../../infrastructure/pristine-store-service.js'; import { SdkWritePrompts } from '../../prompts/sdk/write.js'; import { DirectoryPath } from '../../types/file/directoryPath.js'; import { RelativeFilePath } from '../../types/file/relativeFilePath.js'; import { SdkContext } from '../../types/sdk-context.js'; +import { SdkRepositoryContext, SdkRepositoryProblem } from '../../types/sdk-repository-context.js'; import { CommitId } from '../../types/sdk/commit-id.js'; import { SdkGeneration } from '../../types/sdk/generation-record.js'; import { SdkDestination } from '../../types/sdk/sdk-destination.js'; -import { SdkRepository } from '../../types/sdk/sdk-repository.js'; import { TreeId } from '../../types/sdk/tree-id.js'; import { ActionResult } from '../action-result.js'; @@ -18,7 +17,6 @@ const MERGE_NEEDS_RESOLVING = 'Resolve the conflicts and rerun'; /** Writes a freshly generated SDK into its directory the way the destination was prepared for. */ export class SdkWriteAction { private readonly prompts = new SdkWritePrompts(); - private readonly store = new PristineStoreService(); public readonly execute = async ( destination: SdkDestination, @@ -49,13 +47,13 @@ export class SdkWriteAction { // The repository is made once the generation has succeeded, so a failed one leaves none behind, // and before the write, which the generator's output cannot collide with. private async overwriteAndRecord( - enclosing: SdkRepository | undefined, + enclosing: SdkRepositoryContext | undefined, sdkContext: SdkContext, tempSdk: DirectoryPath, generation: SdkGeneration ): Promise { const sdk = sdkContext.directory(); - const repository = enclosing === undefined ? await this.store.initRepository(sdk) : ok(enclosing); + const repository = enclosing === undefined ? await sdkContext.initRepository() : ok(enclosing); if (repository.isErr()) { return this.customCodeFailed(repository.error); } @@ -67,36 +65,36 @@ export class SdkWriteAction { if (enclosing === undefined) { this.prompts.repositoryInitialised(sdk); } - const generated = await this.store.snapshotGenerated(repository.value, tempSdk); + const generated = await repository.value.snapshotGenerated(tempSdk); await this.record(repository.value, generated, generation, []); this.prompts.sdkGenerated(sdk); return ActionResult.success(); } private async merge( - repository: SdkRepository, + repository: SdkRepositoryContext, base: CommitId, tempSdk: DirectoryPath, generation: SdkGeneration ): Promise> { - const generated = await this.store.snapshotGenerated(repository, tempSdk); + const generated = await repository.snapshotGenerated(tempSdk); if (generated.isErr()) { return this.customCodeFailed(generated.error); } - const previous = await this.store.savePrevious(repository, base); + const previous = await repository.savePrevious(base); if (previous.isErr()) { return this.customCodeFailed(previous.error); } - const disk = await this.store.snapshotDisk(repository, base); + const disk = await repository.snapshotDisk(base); if (disk.isErr()) { return this.customCodeFailed(disk.error); } - const merged = await this.store.merge(repository, base, disk.value, generated.value); + const merged = await repository.merge(base, disk.value, generated.value); if (merged.isErr()) { return this.customCodeFailed(merged.error); } const { plan } = merged.value; - const applied = await this.store.apply(repository, plan); + const applied = await repository.apply(plan); if (applied.isErr()) { return this.customCodeFailed(applied.error); } @@ -105,7 +103,7 @@ export class SdkWriteAction { if (!plan.hasConflicts()) { return ActionResult.success(); } - const staged = await this.store.stageConflicts(repository, merged.value); + const staged = await repository.stageConflicts(merged.value); if (staged.isErr()) { this.prompts.conflictsNotStaged(staged.error.reason); } @@ -121,13 +119,13 @@ export class SdkWriteAction { // The SDK is already on disk when this runs, so a failure here is a warning: the next run // asks before replacing it, as it would any directory without a record. private async record( - repository: SdkRepository, - generated: Result, + repository: SdkRepositoryContext, + generated: Result, generation: SdkGeneration, conflicts: readonly RelativeFilePath[] ): Promise { const recorded = await generated.match( - (tree) => this.store.recordGeneration(repository, tree, generation, envInfo.getCLIVersion(), conflicts), + (tree) => repository.recordGeneration(tree, generation, envInfo.getCLIVersion(), conflicts), async (problem) => err(problem) ); if (recorded.isErr()) { diff --git a/src/infrastructure/pristine-store-service.ts b/src/infrastructure/pristine-store-service.ts deleted file mode 100644 index cad346b6a..000000000 --- a/src/infrastructure/pristine-store-service.ts +++ /dev/null @@ -1,358 +0,0 @@ -import fs from 'node:fs'; -import path from 'node:path'; -import { err, ok, Result } from 'neverthrow'; -import { hasConflictMarkers, relabelMarkers } from '../application/sdk-merge/markers.js'; -import { MergePlan } from '../application/sdk-merge/merge-plan.js'; -import { parseMergeTreeOutput, StageEntry } from '../application/sdk-merge/merge-tree-output.js'; -import { planMerge } from '../application/sdk-merge/sdk-merge.js'; -import { DirectoryPath } from '../types/file/directoryPath.js'; -import { FileName } from '../types/file/fileName.js'; -import { FilePath } from '../types/file/filePath.js'; -import { RelativeFilePath } from '../types/file/relativeFilePath.js'; -import { CommitId } from '../types/sdk/commit-id.js'; -import { describeGeneration, GenerationRecord, SdkGeneration } from '../types/sdk/generation-record.js'; -import { SdkRepository } from '../types/sdk/sdk-repository.js'; -import { SnapshotFiles } from '../types/sdk/snapshot-files.js'; -import { SnapshotRef } from '../types/sdk/snapshot-ref.js'; -import { TreeId } from '../types/sdk/tree-id.js'; -import { errorMessage } from '../utils/error-utils.js'; -import { FileService } from './file-service.js'; -import { GitService } from './git-service.js'; -import { canonical } from './tmp-extensions.js'; - -export type PristineStoreProblem = { - kind: 'nestedSdkDirectory' | 'snapshotMissing' | 'gitFailed' | 'recordNotWritten'; - reason: string; -}; - -/** What the store knows about an SDK directory before a run, from the repository and the record. */ -export type SdkDirectoryState = - | { kind: 'noRepository' } - | { kind: 'noRecord'; repository: SdkRepository } - | { kind: 'otherGeneration'; repository: SdkRepository; recorded: SdkGeneration } - | { kind: 'snapshotMissing'; repository: SdkRepository; commit: CommitId } - | { kind: 'unresolved'; repository: SdkRepository; paths: RelativeFilePath[] } - | { kind: 'ready'; repository: SdkRepository; base: CommitId }; - -/** The plan, the merged tree, and git's versions of every conflicted path, below the SDK directory. */ -export type MergeOutcome = { plan: MergePlan; merged: TreeId; stages: StageEntry[] }; - -const RECORD_DIRECTORY = '.apimatic'; -const RECORD_FILE = 'generation.json'; - -/** - * The directory spelt as git will spell it, and its nearest ancestor that exists, which is where - * git can be asked about it: the SDK directory of a first run is not there yet, and on Windows - * the one the caller holds may be an 8.3 alias where git answers with the long name. - */ -function realSpelling(directory: DirectoryPath): { real: DirectoryPath; existing: DirectoryPath } { - let existing = directory.toString(); - const missing: string[] = []; - while (!fs.existsSync(existing)) { - missing.unshift(path.basename(existing)); - existing = path.dirname(existing); - } - const existingReal = new DirectoryPath(canonical(existing)); - return { real: existingReal.join(...missing), existing: existingReal }; -} - -/** - * The snapshots behind custom code, kept as commits under hidden refs of the repository that - * encloses an SDK directory: the pristine chain (one commit per generation, the base of the - * next merge) and the previous state (the directory before the last run wrote to it), and - * the merge of a regeneration over the directory against that base. The repository is never - * pushed, fetched, or checked out from here. - */ -export class PristineStoreService { - private readonly fileService = new FileService(); - private readonly git = new GitService(); - - /** Everything a run needs to know before it decides how to write; a nested SDK directory is the one refusal. */ - public async inspect( - sdkDirectory: DirectoryPath, - generation: SdkGeneration - ): Promise> { - const repository = await this.findRepository(sdkDirectory); - if (repository === undefined) { - return ok({ kind: 'noRepository' }); - } - const notNested = await this.verifyNotNested(repository); - if (notNested.isErr()) { - return err(notNested.error); - } - const record = await this.readRecord(sdkDirectory); - if (record === undefined) { - return ok({ kind: 'noRecord', repository }); - } - if (!record.isFor(generation)) { - return ok({ kind: 'otherGeneration', repository, recorded: record.generatedAs() }); - } - const base = record.pristineCommit(); - if (!(await this.git.hasCommit(repository.root(), base))) { - return ok({ kind: 'snapshotMissing', repository, commit: base }); - } - const paths = await this.unresolvedConflicts(repository, record); - return ok(paths.length > 0 ? { kind: 'unresolved', repository, paths } : { kind: 'ready', repository, base }); - } - - public async findRepository(sdkDirectory: DirectoryPath): Promise { - const { real, existing } = realSpelling(sdkDirectory); - const root = await this.git.topLevel(existing); - return root === undefined ? undefined : SdkRepository.create(new DirectoryPath(canonical(root.toString())), real); - } - - public async initRepository(sdkDirectory: DirectoryPath): Promise> { - try { - await this.fileService.createDirectoryIfNotExists(sdkDirectory); - await this.git.init(sdkDirectory); - const repository = SdkRepository.create(sdkDirectory, sdkDirectory); - return repository === undefined ? err(gitFailed('the repository could not be opened')) : ok(repository); - } catch (error) { - return err(gitFailed(errorMessage(error))); - } - } - - /** The newest pristine snapshot of the SDK directory, or none when nothing was recorded there. */ - public async pristineTip(repository: SdkRepository): Promise { - return this.git.resolveRef(repository.root(), repository.pristineRef()); - } - - /** A freshly generated SDK as a tree: every file, whatever its own `.gitignore` says. */ - public async snapshotGenerated( - repository: SdkRepository, - generated: DirectoryPath - ): Promise> { - try { - return ok(await this.git.treeOf(repository.root(), generated, 'all')); - } catch (error) { - return err(gitFailed(errorMessage(error))); - } - } - - /** - * The SDK directory as it is on disk: its `.gitignore` is honoured, except that every path - * `base` holds goes in, so a generated file an ignore rule matches is never taken for deleted. - */ - public async snapshotDisk( - repository: SdkRepository, - base: CommitId | undefined - ): Promise> { - try { - const include = - base === undefined ? [] : (await this.git.listTree(repository.root(), base)).map((entry) => entry.path); - return ok(await this.git.treeOf(repository.root(), repository.sdkDirectory(), include)); - } catch (error) { - return err(gitFailed(errorMessage(error))); - } - } - - /** Records `generated` as the next pristine snapshot of the SDK directory, chained on the last one; an identical generation keeps the last. */ - public async recordPristine( - repository: SdkRepository, - generated: TreeId, - generation: SdkGeneration - ): Promise> { - const notNested = await this.verifyNotNested(repository); - if (notNested.isErr()) { - return err(notNested.error); - } - const ref = repository.pristineRef(); - try { - const parent = await this.git.resolveRef(repository.root(), ref); - if (parent !== undefined && (await this.git.treeOfCommit(repository.root(), parent)).isEqual(generated)) { - return ok(parent); - } - const commit = await this.git.commitTree( - repository.root(), - generated, - parent === undefined ? [] : [parent], - `apimatic: pristine ${describeGeneration(generation)}` - ); - await this.git.updateRef(repository.root(), ref, commit); - return ok(commit); - } catch (error) { - return err(gitFailed(errorMessage(error))); - } - } - - /** The pristine snapshot first, then the record naming it, so the two never disagree. */ - public async recordGeneration( - repository: SdkRepository, - generated: TreeId, - generation: SdkGeneration, - cliVersion: string, - conflicts: readonly RelativeFilePath[] - ): Promise> { - const pristine = await this.recordPristine(repository, generated, generation); - if (pristine.isErr()) { - return err(pristine.error); - } - const record = GenerationRecord.written(generation, pristine.value, new Date(), cliVersion, conflicts); - return (await this.writeRecord(repository.sdkDirectory(), record)).map(() => record); - } - - /** Keeps the SDK directory as it is on disk, replacing what the last run kept, so a merge can be undone. */ - public async savePrevious( - repository: SdkRepository, - base: CommitId | undefined - ): Promise> { - const disk = await this.snapshotDisk(repository, base); - if (disk.isErr()) { - return err(disk.error); - } - try { - const commit = await this.git.commitTree(repository.root(), disk.value, [], 'apimatic: before regeneration'); - await this.git.updateRef(repository.root(), repository.previousRef(), commit); - return ok(commit); - } catch (error) { - return err(gitFailed(errorMessage(error))); - } - } - - public async readSnapshot( - repository: SdkRepository, - commit: CommitId - ): Promise> { - const root = repository.root(); - try { - if (!(await this.git.hasCommit(root, commit))) { - return err({ kind: 'snapshotMissing', reason: `commit ${commit} is not in the repository` }); - } - const entries = (await this.git.listTree(root, commit)).filter((entry) => entry.type === 'blob'); - const blobs = await this.git.readBlobs(root, [...new Set(entries.map((entry) => entry.oid))]); - return ok(new Map(entries.map((entry) => [entry.path.toString(), blobs.get(entry.oid)!]))); - } catch (error) { - return err(gitFailed(errorMessage(error))); - } - } - - public async readRecord(sdkDirectory: DirectoryPath): Promise { - const file = this.recordFile(sdkDirectory); - if (!(await this.fileService.fileExists(file))) { - return undefined; - } - return GenerationRecord.parse(await this.fileService.getContents(file)); - } - - public async writeRecord( - sdkDirectory: DirectoryPath, - record: GenerationRecord - ): Promise> { - const file = this.recordFile(sdkDirectory); - try { - await this.fileService.replaceContents(file, record.toJson()); - return ok(file); - } catch (error) { - return err({ kind: 'recordNotWritten', reason: errorMessage(error) }); - } - } - - /** - * The three-way merge, done by git: the disk and the fresh generation are committed on the - * pristine base as temporary commits, `merge-tree` merges them, and the plan says which - * paths differ between the disk and the result. - */ - public async merge( - repository: SdkRepository, - base: CommitId, - disk: TreeId, - generated: TreeId - ): Promise> { - const root = repository.root(); - try { - const baseTree = await this.git.treeOfCommit(root, base); - const yours = await this.git.commitTree(root, disk, [base], 'apimatic: your changes'); - const theirs = await this.git.commitTree(root, generated, [base], 'apimatic: generated'); - const run = await this.git.mergeTree(root, yours, theirs); - const output = parseMergeTreeOutput(run.output); - const merged = TreeId.fromGit(output.tree); - const plan = planMerge({ - merge: output, - yours: await this.git.diffTrees(root, baseTree, disk, true), - generated: await this.git.diffTrees(root, baseTree, generated, true), - applied: await this.git.diffTrees(root, disk, merged, false), - basePaths: (await this.git.listTree(root, baseTree)).map((entry) => entry.path) - }); - return ok({ plan, merged, stages: output.stages }); - } catch (error) { - return err(gitFailed(errorMessage(error))); - } - } - - /** Writes and removes what the plan says, in the SDK directory, with the CLI's labels on any markers. */ - public async apply(repository: SdkRepository, plan: MergePlan): Promise> { - const sdk = repository.sdkDirectory(); - try { - const blobs = await this.git.readBlobs(repository.root(), [...new Set(plan.writes.map((write) => write.blob))]); - for (const write of plan.writes) { - const content = blobs.get(write.blob)!; - const target = write.path.in(sdk).toString(); - await fs.promises.mkdir(path.dirname(target), { recursive: true }); - await fs.promises.writeFile(target, plan.hasMarkers(write.path) ? relabelMarkers(content) : content); - } - for (const relative of plan.deletes) { - await fs.promises.rm(relative.in(sdk).toString(), { force: true }); - } - return ok(undefined); - } catch (error) { - return err(gitFailed(errorMessage(error))); - } - } - - /** Marks the conflicted paths in the repository's index the way a merge would, so git's own tools take over. */ - public async stageConflicts( - repository: SdkRepository, - outcome: MergeOutcome - ): Promise> { - try { - await this.git.stageConflicts( - repository.root(), - outcome.stages.map((entry) => ({ ...entry, path: repository.rootRelative(entry.path) })) - ); - return ok(undefined); - } catch (error) { - return err(gitFailed(errorMessage(error))); - } - } - - // The ref name is the SDK directory's path, so one SDK directory inside another cannot have its own. - private async verifyNotNested(repository: SdkRepository): Promise> { - const ref = repository.pristineRef(); - try { - const colliding = (await this.pristineRefsIn(repository)).find((existing) => existing.collidesWith(ref)); - return colliding === undefined - ? ok(undefined) - : err({ - kind: 'nestedSdkDirectory', - reason: `${ref} cannot be kept beside ${colliding}: one SDK directory sits inside another` - }); - } catch (error) { - return err(gitFailed(errorMessage(error))); - } - } - - // The files the record lists as conflicted that still hold the markers, so a run never merges over them. - private async unresolvedConflicts(repository: SdkRepository, record: GenerationRecord): Promise { - const unresolved: RelativeFilePath[] = []; - for (const relative of record.conflictedPaths()) { - const file = relative.in(repository.sdkDirectory()); - if ((await this.fileService.fileExists(file)) && hasConflictMarkers(await this.fileService.getContents(file))) { - unresolved.push(relative); - } - } - return unresolved; - } - - private recordFile(sdkDirectory: DirectoryPath): FilePath { - return new FilePath(sdkDirectory.join(RECORD_DIRECTORY), new FileName(RECORD_FILE)); - } - - private async pristineRefsIn(repository: SdkRepository): Promise { - const namespace = SnapshotRef.pristineNamespace(); - return (await this.git.listRefs(repository.root(), namespace)).map((name) => - SnapshotRef.pristine(name === namespace ? '' : name.slice(namespace.length + 1)) - ); - } -} - -const gitFailed = (reason: string): PristineStoreProblem => ({ kind: 'gitFailed', reason }); diff --git a/src/prompts/sdk/prepare.ts b/src/prompts/sdk/prepare.ts index 0d6cdf617..d6539194d 100644 --- a/src/prompts/sdk/prepare.ts +++ b/src/prompts/sdk/prepare.ts @@ -4,7 +4,7 @@ import { RelativeFilePath } from '../../types/file/relativeFilePath.js'; import { CommitId } from '../../types/sdk/commit-id.js'; import { describeGeneration, SdkGeneration } from '../../types/sdk/generation-record.js'; import { GitVersion } from '../../types/sdk/git-version.js'; -import { SdkRepository } from '../../types/sdk/sdk-repository.js'; +import { SdkRepositoryContext } from '../../types/sdk-repository-context.js'; import { SnapshotRef } from '../../types/sdk/snapshot-ref.js'; import { format as f } from '../format.js'; @@ -52,7 +52,7 @@ export class SdkPreparePrompts { log.warn(message); } - public snapshotMissing(commit: CommitId, repository: SdkRepository) { + public snapshotMissing(commit: CommitId, repository: SdkRepositoryContext) { const refs = `'${SnapshotRef.pristineNamespace()}/*:${SnapshotRef.pristineNamespace()}/*'`; const message = `The snapshot this SDK was generated from, commit ${f.var(commit.toString())}, is not in the repository at ` + @@ -63,7 +63,7 @@ export class SdkPreparePrompts { log.warn(message); } - public conflictsUnresolved(paths: readonly RelativeFilePath[], repository: SdkRepository) { + public conflictsUnresolved(paths: readonly RelativeFilePath[], repository: SdkRepositoryContext) { const one = paths.length === 1; const message = `${plural(paths.length, 'file')} still hold${one ? 's' : ''} conflict markers from the last run. ` + diff --git a/src/prompts/sdk/write.ts b/src/prompts/sdk/write.ts index 200e39533..0e5fa898d 100644 --- a/src/prompts/sdk/write.ts +++ b/src/prompts/sdk/write.ts @@ -3,7 +3,7 @@ import { Conflict, ConflictKind, MergeFiles, MergePlan } from '../../application import { DirectoryPath } from '../../types/file/directoryPath.js'; import { RelativeFilePath } from '../../types/file/relativeFilePath.js'; import { SdkSaveProblem } from '../../types/sdk-context.js'; -import { SdkRepository } from '../../types/sdk/sdk-repository.js'; +import { SdkRepositoryContext } from '../../types/sdk-repository-context.js'; import { listedInProse } from '../../utils/string-utils.js'; import { format as f } from '../format.js'; import { noteWrapped } from '../prompt.js'; @@ -28,7 +28,7 @@ const YOUR_FILES: Readonly>> = { const plural = (count: number, noun: string) => `${count} ${noun}${count === 1 ? '' : 's'}`; -const listed = (repository: SdkRepository, paths: readonly RelativeFilePath[]) => +const listed = (repository: SdkRepositoryContext, paths: readonly RelativeFilePath[]) => paths.map((relative) => ` ${f.relativePath(relative.in(repository.sdkDirectory()))}`); export class SdkWritePrompts { @@ -65,7 +65,7 @@ export class SdkWritePrompts { log.warn(message); } - public sdkMerged(repository: SdkRepository, plan: MergePlan) { + public sdkMerged(repository: SdkRepositoryContext, plan: MergePlan) { const generated = GENERATOR_FILES.filter((kind) => plan.files[kind].length > 0).map( (kind) => `${plural(plan.files[kind].length, 'file')} ${kind}` ); @@ -82,7 +82,7 @@ export class SdkWritePrompts { log.info(lines.join('\n')); } - public conflicts(conflicts: readonly Conflict[], repository: SdkRepository) { + public conflicts(conflicts: readonly Conflict[], repository: SdkRepositoryContext) { const lines = conflicts.map( (conflict) => `${f.relativePath(conflict.path.in(repository.sdkDirectory()))}\n ${ diff --git a/src/types/sdk-context.ts b/src/types/sdk-context.ts index 77413e30e..583091dd8 100644 --- a/src/types/sdk-context.ts +++ b/src/types/sdk-context.ts @@ -1,19 +1,42 @@ -import { ok, Result } from 'neverthrow'; +import fs from 'node:fs'; +import path from 'node:path'; +import { err, ok, Result } from 'neverthrow'; import { FileService } from '../infrastructure/file-service.js'; +import { GitService } from '../infrastructure/git-service.js'; +import { canonical } from '../infrastructure/tmp-extensions.js'; +import { ZipService } from '../infrastructure/zip-service.js'; +import { errorMessage } from '../utils/error-utils.js'; import { DirectoryPath } from './file/directoryPath.js'; import { FilePath } from './file/filePath.js'; import { FileName } from './file/fileName.js'; import { FileProblem } from './file/file-problem.js'; import { Language } from './sdk/generate.js'; -import { ZipService } from '../infrastructure/zip-service.js'; +import { SdkRepositoryContext, SdkRepositoryProblem } from './sdk-repository-context.js'; export type SdkSaveProblem = FileProblem & { sdkDirectory: DirectoryPath }; const GIT_DIRECTORY = '.git'; +/** + * The directory spelt as git will spell it, and its nearest ancestor that exists, which is where + * git can be asked about it: the SDK directory of a first run is not there yet, and on Windows + * the one the caller holds may be an 8.3 alias where git answers with the long name. + */ +function realSpelling(directory: DirectoryPath): { real: DirectoryPath; existing: DirectoryPath } { + let existing = directory.toString(); + const missing: string[] = []; + while (!fs.existsSync(existing)) { + missing.unshift(path.basename(existing)); + existing = path.dirname(existing); + } + const existingReal = new DirectoryPath(canonical(existing)); + return { real: existingReal.join(...missing), existing: existingReal }; +} + export class SdkContext { private readonly fileService = new FileService(); private readonly zipService = new ZipService(); + private readonly git = new GitService(); private readonly sdkDirectory: DirectoryPath; constructor(private readonly language: Language, sdkDirectory: DirectoryPath, version?: string) { @@ -33,6 +56,29 @@ export class SdkContext { return !(await this.fileService.directoryEmpty(this.sdkDirectory)); } + /** The repository enclosing the SDK directory, which need not exist yet, or none when nothing does. */ + public async repository(): Promise { + const { real, existing } = realSpelling(this.sdkDirectory); + const root = await this.git.topLevel(existing); + return root === undefined + ? undefined + : SdkRepositoryContext.create(new DirectoryPath(canonical(root.toString())), real); + } + + /** Makes the SDK directory a repository of its own, creating it when it is not there yet. */ + public async initRepository(): Promise> { + try { + await this.fileService.createDirectoryIfNotExists(this.sdkDirectory); + await this.git.init(this.sdkDirectory); + const repository = SdkRepositoryContext.create(this.sdkDirectory, this.sdkDirectory); + return repository === undefined + ? err({ kind: 'gitFailed', reason: 'the repository could not be opened' }) + : ok(repository); + } catch (error) { + return err({ kind: 'gitFailed', reason: errorMessage(error) }); + } + } + /** Overwrites the directory; a `.git` in it is the developer's, or holds the snapshots, and stays. */ public async save(tempSdkDirectory: DirectoryPath, zipSdk: boolean): Promise> { await this.fileService.cleanDirectoryExcluding(this.sdkDirectory, [new FileName(GIT_DIRECTORY)]); diff --git a/src/types/sdk-repository-context.ts b/src/types/sdk-repository-context.ts new file mode 100644 index 000000000..65f6a63f8 --- /dev/null +++ b/src/types/sdk-repository-context.ts @@ -0,0 +1,309 @@ +import fs from 'node:fs'; +import path from 'node:path'; +import { err, ok, Result } from 'neverthrow'; +import { hasConflictMarkers, relabelMarkers } from '../application/sdk-merge/markers.js'; +import { MergePlan } from '../application/sdk-merge/merge-plan.js'; +import { parseMergeTreeOutput, StageEntry } from '../application/sdk-merge/merge-tree-output.js'; +import { planMerge } from '../application/sdk-merge/sdk-merge.js'; +import { FileService } from '../infrastructure/file-service.js'; +import { GitService } from '../infrastructure/git-service.js'; +import { errorMessage } from '../utils/error-utils.js'; +import { DirectoryPath, posix } from './file/directoryPath.js'; +import { FileName } from './file/fileName.js'; +import { FilePath } from './file/filePath.js'; +import { RelativeFilePath } from './file/relativeFilePath.js'; +import { CommitId } from './sdk/commit-id.js'; +import { describeGeneration, GenerationRecord, SdkGeneration } from './sdk/generation-record.js'; +import { SnapshotRef } from './sdk/snapshot-ref.js'; +import { TreeId } from './sdk/tree-id.js'; + +export type SdkRepositoryProblem = { + kind: 'nestedSdkDirectory' | 'gitFailed' | 'recordNotWritten'; + reason: string; +}; + +/** What the repository knows about its SDK directory before a run, from the record and the refs. */ +export type SdkRepositoryState = + | { kind: 'noRecord' } + | { kind: 'otherGeneration'; recorded: SdkGeneration } + | { kind: 'snapshotMissing'; commit: CommitId } + | { kind: 'unresolved'; paths: RelativeFilePath[] } + | { kind: 'ready'; base: CommitId }; + +/** The plan, the merged tree, and git's versions of every conflicted path, below the SDK directory. */ +export type MergeOutcome = { plan: MergePlan; merged: TreeId; stages: StageEntry[] }; + +const RECORD_DIRECTORY = '.apimatic'; +const RECORD_FILE = 'generation.json'; + +/** + * An SDK directory together with the git repository that encloses it, where its snapshots live + * as commits under hidden refs: the pristine chain (one commit per generation, the base of the + * next merge) and the previous state (the directory before the last run wrote to it). It merges + * a regeneration over the directory against that base. The repository is never pushed, fetched, + * or checked out from here. + */ +export class SdkRepositoryContext { + private readonly fileService = new FileService(); + private readonly git = new GitService(); + + private constructor(private readonly rootDirectory: DirectoryPath, private readonly sdk: DirectoryPath) {} + + public static create(root: DirectoryPath, sdkDirectory: DirectoryPath): SdkRepositoryContext | undefined { + return root.contains(sdkDirectory) ? new SdkRepositoryContext(root, sdkDirectory) : undefined; + } + + public root(): DirectoryPath { + return this.rootDirectory; + } + + public sdkDirectory(): DirectoryPath { + return this.sdk; + } + + /** True when the SDK directory is the repository itself, so its `.git` sits among the SDK files. */ + public isRootSdk(): boolean { + return this.rootDirectory.isEqual(this.sdk); + } + + public pristineRef(): SnapshotRef { + return SnapshotRef.pristine(this.sdkPath()); + } + + public previousRef(): SnapshotRef { + return SnapshotRef.previous(this.sdkPath()); + } + + /** A path below the SDK directory as git names it from the repository root. */ + public rootRelative(relative: RelativeFilePath): RelativeFilePath { + return relative.below(this.sdkPath()); + } + + /** Everything a run needs to know before it decides how to write; a nested SDK directory is the one refusal. */ + public async inspect(generation: SdkGeneration): Promise> { + const notNested = await this.verifyNotNested(); + if (notNested.isErr()) { + return err(notNested.error); + } + const record = await this.readRecord(); + if (record === undefined) { + return ok({ kind: 'noRecord' }); + } + if (!record.isFor(generation)) { + return ok({ kind: 'otherGeneration', recorded: record.generatedAs() }); + } + const base = record.pristineCommit(); + if (!(await this.git.hasCommit(this.rootDirectory, base))) { + return ok({ kind: 'snapshotMissing', commit: base }); + } + const paths = await this.unresolvedConflicts(record); + return ok(paths.length > 0 ? { kind: 'unresolved', paths } : { kind: 'ready', base }); + } + + /** A freshly generated SDK as a tree: every file, whatever its own `.gitignore` says. */ + public async snapshotGenerated(generated: DirectoryPath): Promise> { + try { + return ok(await this.git.treeOf(this.rootDirectory, generated, 'all')); + } catch (error) { + return err(gitFailed(errorMessage(error))); + } + } + + /** + * The SDK directory as it is on disk: its `.gitignore` is honoured, except that every path + * `base` holds goes in, so a generated file an ignore rule matches is never taken for deleted. + */ + public async snapshotDisk(base: CommitId | undefined): Promise> { + try { + const include = + base === undefined ? [] : (await this.git.listTree(this.rootDirectory, base)).map((entry) => entry.path); + return ok(await this.git.treeOf(this.rootDirectory, this.sdk, include)); + } catch (error) { + return err(gitFailed(errorMessage(error))); + } + } + + /** Records `generated` as the next pristine snapshot, chained on the last one; an identical generation keeps the last. */ + public async recordPristine( + generated: TreeId, + generation: SdkGeneration + ): Promise> { + const notNested = await this.verifyNotNested(); + if (notNested.isErr()) { + return err(notNested.error); + } + const ref = this.pristineRef(); + try { + const parent = await this.git.resolveRef(this.rootDirectory, ref); + if (parent !== undefined && (await this.git.treeOfCommit(this.rootDirectory, parent)).isEqual(generated)) { + return ok(parent); + } + const commit = await this.git.commitTree( + this.rootDirectory, + generated, + parent === undefined ? [] : [parent], + `apimatic: pristine ${describeGeneration(generation)}` + ); + await this.git.updateRef(this.rootDirectory, ref, commit); + return ok(commit); + } catch (error) { + return err(gitFailed(errorMessage(error))); + } + } + + /** The pristine snapshot first, then the record naming it, so the two never disagree. */ + public async recordGeneration( + generated: TreeId, + generation: SdkGeneration, + cliVersion: string, + conflicts: readonly RelativeFilePath[] + ): Promise> { + const pristine = await this.recordPristine(generated, generation); + if (pristine.isErr()) { + return err(pristine.error); + } + const record = GenerationRecord.written(generation, pristine.value, new Date(), cliVersion, conflicts); + return (await this.writeRecord(record)).map(() => record); + } + + /** Keeps the SDK directory as it is on disk, replacing what the last run kept, so a merge can be undone. */ + public async savePrevious(base: CommitId | undefined): Promise> { + const disk = await this.snapshotDisk(base); + if (disk.isErr()) { + return err(disk.error); + } + try { + const commit = await this.git.commitTree(this.rootDirectory, disk.value, [], 'apimatic: before regeneration'); + await this.git.updateRef(this.rootDirectory, this.previousRef(), commit); + return ok(commit); + } catch (error) { + return err(gitFailed(errorMessage(error))); + } + } + + public async readRecord(): Promise { + const file = this.recordFile(); + if (!(await this.fileService.fileExists(file))) { + return undefined; + } + return GenerationRecord.parse(await this.fileService.getContents(file)); + } + + /** + * The three-way merge, done by git: the disk and the fresh generation are committed on the + * pristine base as temporary commits, `merge-tree` merges them, and the plan says which + * paths differ between the disk and the result. + */ + public async merge( + base: CommitId, + disk: TreeId, + generated: TreeId + ): Promise> { + const root = this.rootDirectory; + try { + const baseTree = await this.git.treeOfCommit(root, base); + const yours = await this.git.commitTree(root, disk, [base], 'apimatic: your changes'); + const theirs = await this.git.commitTree(root, generated, [base], 'apimatic: generated'); + const run = await this.git.mergeTree(root, yours, theirs); + const output = parseMergeTreeOutput(run.output); + const merged = TreeId.fromGit(output.tree); + const plan = planMerge({ + merge: output, + yours: await this.git.diffTrees(root, baseTree, disk, true), + generated: await this.git.diffTrees(root, baseTree, generated, true), + applied: await this.git.diffTrees(root, disk, merged, false), + basePaths: (await this.git.listTree(root, baseTree)).map((entry) => entry.path) + }); + return ok({ plan, merged, stages: output.stages }); + } catch (error) { + return err(gitFailed(errorMessage(error))); + } + } + + /** Writes and removes what the plan says, in the SDK directory, with the CLI's labels on any markers. */ + public async apply(plan: MergePlan): Promise> { + try { + const blobs = await this.git.readBlobs(this.rootDirectory, [...new Set(plan.writes.map((write) => write.blob))]); + for (const write of plan.writes) { + const content = blobs.get(write.blob)!; + const target = write.path.in(this.sdk).toString(); + await fs.promises.mkdir(path.dirname(target), { recursive: true }); + await fs.promises.writeFile(target, plan.hasMarkers(write.path) ? relabelMarkers(content) : content); + } + for (const relative of plan.deletes) { + await fs.promises.rm(relative.in(this.sdk).toString(), { force: true }); + } + return ok(undefined); + } catch (error) { + return err(gitFailed(errorMessage(error))); + } + } + + /** Marks the conflicted paths in the repository's index the way a merge would, so git's own tools take over. */ + public async stageConflicts(outcome: MergeOutcome): Promise> { + try { + await this.git.stageConflicts( + this.rootDirectory, + outcome.stages.map((entry) => ({ ...entry, path: this.rootRelative(entry.path) })) + ); + return ok(undefined); + } catch (error) { + return err(gitFailed(errorMessage(error))); + } + } + + // The ref name is the SDK directory's path, so one SDK directory inside another cannot have its own. + private async verifyNotNested(): Promise> { + const ref = this.pristineRef(); + try { + const colliding = (await this.pristineRefsIn()).find((existing) => existing.collidesWith(ref)); + return colliding === undefined + ? ok(undefined) + : err({ + kind: 'nestedSdkDirectory', + reason: `${ref} cannot be kept beside ${colliding}: one SDK directory sits inside another` + }); + } catch (error) { + return err(gitFailed(errorMessage(error))); + } + } + + private async writeRecord(record: GenerationRecord): Promise> { + const file = this.recordFile(); + try { + await this.fileService.replaceContents(file, record.toJson()); + return ok(file); + } catch (error) { + return err({ kind: 'recordNotWritten', reason: errorMessage(error) }); + } + } + + // The files the record lists as conflicted that still hold the markers, so a run never merges over them. + private async unresolvedConflicts(record: GenerationRecord): Promise { + const unresolved: RelativeFilePath[] = []; + for (const relative of record.conflictedPaths()) { + const file = relative.in(this.sdk); + if ((await this.fileService.fileExists(file)) && hasConflictMarkers(await this.fileService.getContents(file))) { + unresolved.push(relative); + } + } + return unresolved; + } + + private recordFile(): FilePath { + return new FilePath(this.sdk.join(RECORD_DIRECTORY), new FileName(RECORD_FILE)); + } + + private async pristineRefsIn(): Promise { + const namespace = SnapshotRef.pristineNamespace(); + return (await this.git.listRefs(this.rootDirectory, namespace)).map((name) => + SnapshotRef.pristine(name === namespace ? '' : name.slice(namespace.length + 1)) + ); + } + + private sdkPath(): string { + return posix(path.relative(this.rootDirectory.toString(), this.sdk.toString())); + } +} + +const gitFailed = (reason: string): SdkRepositoryProblem => ({ kind: 'gitFailed', reason }); diff --git a/src/types/sdk/sdk-destination.ts b/src/types/sdk/sdk-destination.ts index 72e5c5f2e..9af75eb80 100644 --- a/src/types/sdk/sdk-destination.ts +++ b/src/types/sdk/sdk-destination.ts @@ -1,5 +1,5 @@ import { CommitId } from './commit-id.js'; -import { SdkRepository } from './sdk-repository.js'; +import { SdkRepositoryContext } from '../sdk-repository-context.js'; /** * How a run will write the SDK directory, decided before anything is uploaded: replaced as @@ -8,5 +8,5 @@ import { SdkRepository } from './sdk-repository.js'; */ export type SdkDestination = | { kind: 'overwrite'; zip: boolean } - | { kind: 'record'; repository: SdkRepository | undefined } - | { kind: 'merge'; repository: SdkRepository; base: CommitId }; + | { kind: 'record'; repository: SdkRepositoryContext | undefined } + | { kind: 'merge'; repository: SdkRepositoryContext; base: CommitId }; diff --git a/src/types/sdk/sdk-repository.ts b/src/types/sdk/sdk-repository.ts deleted file mode 100644 index 61c205447..000000000 --- a/src/types/sdk/sdk-repository.ts +++ /dev/null @@ -1,49 +0,0 @@ -import path from 'node:path'; -import { DirectoryPath, posix } from '../file/directoryPath.js'; -import { RelativeFilePath } from '../file/relativeFilePath.js'; -import { SnapshotRef } from './snapshot-ref.js'; - -/** An SDK directory together with the git repository that encloses it, which is where its snapshots live. */ -export class SdkRepository { - private readonly rootDirectory: DirectoryPath; - private readonly sdk: DirectoryPath; - - private constructor(root: DirectoryPath, sdkDirectory: DirectoryPath) { - this.rootDirectory = root; - this.sdk = sdkDirectory; - } - - public static create(root: DirectoryPath, sdkDirectory: DirectoryPath): SdkRepository | undefined { - return root.contains(sdkDirectory) ? new SdkRepository(root, sdkDirectory) : undefined; - } - - public root(): DirectoryPath { - return this.rootDirectory; - } - - public sdkDirectory(): DirectoryPath { - return this.sdk; - } - - /** True when the SDK directory is the repository itself, so its `.git` sits among the SDK files. */ - public isRootSdk(): boolean { - return this.rootDirectory.isEqual(this.sdk); - } - - public pristineRef(): SnapshotRef { - return SnapshotRef.pristine(this.sdkPath()); - } - - public previousRef(): SnapshotRef { - return SnapshotRef.previous(this.sdkPath()); - } - - /** A path below the SDK directory, with `/` between segments, as git names it from the repository root. */ - public rootRelative(relative: RelativeFilePath): RelativeFilePath { - return relative.below(this.sdkPath()); - } - - private sdkPath(): string { - return posix(path.relative(this.rootDirectory.toString(), this.sdk.toString())); - } -} diff --git a/src/types/sdk/snapshot-files.ts b/src/types/sdk/snapshot-files.ts deleted file mode 100644 index ece0c9d3d..000000000 --- a/src/types/sdk/snapshot-files.ts +++ /dev/null @@ -1,2 +0,0 @@ -/** A snapshot's files by their path below the SDK directory, with `/` between segments, as git names them. */ -export type SnapshotFiles = ReadonlyMap; diff --git a/test/actions/sdk/prepare.test.ts b/test/actions/sdk/prepare.test.ts index 1481cb54c..58e5e850a 100644 --- a/test/actions/sdk/prepare.test.ts +++ b/test/actions/sdk/prepare.test.ts @@ -8,7 +8,6 @@ import { err } from 'neverthrow'; import sinon from 'sinon'; import { SdkPrepareAction } from '../../../src/actions/sdk/prepare.js'; import { GitService } from '../../../src/infrastructure/git-service.js'; -import { PristineStoreService } from '../../../src/infrastructure/pristine-store-service.js'; import { SdkPreparePrompts } from '../../../src/prompts/sdk/prepare.js'; import { DirectoryPath } from '../../../src/types/file/directoryPath.js'; import { SdkContext } from '../../../src/types/sdk-context.js'; @@ -16,7 +15,7 @@ import { CommitId } from '../../../src/types/sdk/commit-id.js'; import { CodeGenerationVersion, Language, Stability } from '../../../src/types/sdk/generate.js'; import { GenerationRecord, SdkGeneration } from '../../../src/types/sdk/generation-record.js'; import { GitVersion } from '../../../src/types/sdk/git-version.js'; -import { SdkRepository } from '../../../src/types/sdk/sdk-repository.js'; +import { SdkRepositoryContext } from '../../../src/types/sdk-repository-context.js'; const STABLE: SdkGeneration = { language: Language.PYTHON, @@ -31,7 +30,6 @@ describe('SdkPrepareAction', () => { let root: string; let sdkContext: SdkContext; const git = new GitService(); - const store = new PristineStoreService(); const execute = (options: { force?: boolean; zip?: boolean; generation?: SdkGeneration } = {}) => new SdkPrepareAction().execute( @@ -49,14 +47,14 @@ describe('SdkPrepareAction', () => { }; /** A recorded generation of `files`, so the directory has a base; `conflicts` as the last run left them. */ - const recorded = async (files: Record, conflicts: string[] = []): Promise => { + const recorded = async (files: Record, conflicts: string[] = []): Promise => { for (const [relative, contents] of Object.entries(files)) { write(relative, contents); } await git.init(new DirectoryPath(root)); - const repository = (await store.findRepository(sdk()))!; - const tree = (await store.snapshotGenerated(repository, sdk()))._unsafeUnwrap(); - (await store.recordGeneration(repository, tree, STABLE, '0.0.0', conflicts.map(rel)))._unsafeUnwrap(); + const repository = (await sdkContext.repository())!; + const tree = (await repository.snapshotGenerated(sdk()))._unsafeUnwrap(); + (await repository.recordGeneration(tree, STABLE, '0.0.0', conflicts.map(rel)))._unsafeUnwrap(); return repository; }; @@ -130,8 +128,8 @@ describe('SdkPrepareAction', () => { const sh = (cwd: string, ...args: string[]) => execa('git', ['-c', 'user.name=t', '-c', 'user.email=t@t', ...args], { cwd }); try { - await recorded({ 'README.md': '# sdk' }); - const base = (await store.readRecord(sdk()))!.pristineCommit(); + const repository = await recorded({ 'README.md': '# sdk' }); + const base = (await repository.readRecord())!.pristineCommit(); await sh(root, 'add', '.'); await sh(root, 'commit', '-qm', 'sdk'); await sh(root, 'init', '-q', '--bare', remote); @@ -165,7 +163,7 @@ describe('SdkPrepareAction', () => { expect(destination.kind).to.equal('merge'); expect(destination.kind === 'merge' && destination.repository.root().isEqual(repository.root())).to.be.true; - expect(destination.kind === 'merge' && destination.base.isEqual((await store.readRecord(sdk()))!.pristineCommit())) + expect(destination.kind === 'merge' && destination.base.isEqual((await repository.readRecord())!.pristineCommit())) .to.be.true; expect(overwriteSdk.called).to.be.false; }); @@ -189,7 +187,8 @@ describe('SdkPrepareAction', () => { }); it('reports a directory git could not inspect', async () => { - sinon.stub(PristineStoreService.prototype, 'inspect').resolves(err({ kind: 'gitFailed', reason: 'git broke' })); + await git.init(new DirectoryPath(root)); + sinon.stub(SdkRepositoryContext.prototype, 'inspect').resolves(err({ kind: 'gitFailed', reason: 'git broke' })); const notInspected = sinon.stub(SdkPreparePrompts.prototype, 'directoryNotInspected'); expect((await execute()).isFailed()).to.be.true; diff --git a/test/actions/sdk/write.test.ts b/test/actions/sdk/write.test.ts index 6f38b7fb9..6925cbf79 100644 --- a/test/actions/sdk/write.test.ts +++ b/test/actions/sdk/write.test.ts @@ -2,7 +2,6 @@ import { RelativeFilePath } from '../../../src/types/file/relativeFilePath.js'; import fs from 'fs'; import os from 'os'; import path from 'path'; -import { TextDecoder } from 'node:util'; import { expect } from 'chai'; import { execa } from 'execa'; import { err } from 'neverthrow'; @@ -10,7 +9,6 @@ import sinon from 'sinon'; import { SdkWriteAction } from '../../../src/actions/sdk/write.js'; import { GENERATED_MARKER, YOURS_MARKER } from '../../../src/application/sdk-merge/markers.js'; import { GitService } from '../../../src/infrastructure/git-service.js'; -import { PristineStoreService } from '../../../src/infrastructure/pristine-store-service.js'; import { ZipService } from '../../../src/infrastructure/zip-service.js'; import { SdkWritePrompts } from '../../../src/prompts/sdk/write.js'; import { DirectoryPath } from '../../../src/types/file/directoryPath.js'; @@ -19,7 +17,8 @@ import { CommitId } from '../../../src/types/sdk/commit-id.js'; import { CodeGenerationVersion, Language, Stability } from '../../../src/types/sdk/generate.js'; import { GenerationRecord, SdkGeneration } from '../../../src/types/sdk/generation-record.js'; import { SdkDestination } from '../../../src/types/sdk/sdk-destination.js'; -import { SdkRepository } from '../../../src/types/sdk/sdk-repository.js'; +import { SdkRepositoryContext } from '../../../src/types/sdk-repository-context.js'; +import { snapshotFiles } from '../../helpers/snapshots.js'; import { SnapshotRef } from '../../../src/types/sdk/snapshot-ref.js'; const STABLE: SdkGeneration = { @@ -51,7 +50,6 @@ describe('SdkWriteAction', () => { let sdkContext: SdkContext; let generations = 0; const git = new GitService(); - const store = new PristineStoreService(); const sdk = () => new DirectoryPath(path.join(root, 'sdk', 'python')); const at = (relative: string) => path.join(sdk().toString(), relative); @@ -73,12 +71,12 @@ describe('SdkWriteAction', () => { new SdkWriteAction().execute(destination, sdkContext, generated(files), STABLE); /** A first generation of `files`, written and recorded into the project's repository. */ - const recorded = async (files: Files): Promise<{ repository: SdkRepository; base: CommitId }> => { + const recorded = async (files: Files): Promise<{ repository: SdkRepositoryContext; base: CommitId }> => { write(sdk(), files); await git.init(new DirectoryPath(root)); - const repository = (await store.findRepository(sdk()))!; - const tree = (await store.snapshotGenerated(repository, generated(files)))._unsafeUnwrap(); - const written = (await store.recordGeneration(repository, tree, STABLE, '0.0.0', []))._unsafeUnwrap(); + const repository = (await sdkContext.repository())!; + const tree = (await repository.snapshotGenerated(generated(files)))._unsafeUnwrap(); + const written = (await repository.recordGeneration(tree, STABLE, '0.0.0', []))._unsafeUnwrap(); return { repository, base: written.pristineCommit() }; }; @@ -134,15 +132,15 @@ describe('SdkWriteAction', () => { const pristine = (await git.resolveRef(sdk(), SnapshotRef.pristine('')))!; expect(record().pristineCommit().isEqual(pristine)).to.be.true; expect(record().isFor(STABLE)).to.be.true; - const repository = (await store.findRepository(sdk()))!; - const files = (await store.readSnapshot(repository, pristine))._unsafeUnwrap(); + const repository = (await sdkContext.repository())!; + const files = await snapshotFiles(repository.root(), pristine); expect([...files.keys()]).to.have.members(['README.md', 'a.py']); }); it('records into the enclosing repository under the SDK directory’s ref, keeping what was there out of it', async () => { write(sdk(), { 'old.py': 'old' }); await git.init(new DirectoryPath(root)); - const repository = (await store.findRepository(sdk()))!; + const repository = (await sdkContext.repository())!; const repositoryInitialised = sinon.stub(SdkWritePrompts.prototype, 'repositoryInitialised'); expect((await execute({ kind: 'record', repository }, { 'README.md': '# sdk' })).isSuccess()).to.be.true; @@ -156,7 +154,7 @@ describe('SdkWriteAction', () => { it('still succeeds, with a warning, when the snapshot cannot be recorded', async () => { sinon - .stub(PristineStoreService.prototype, 'recordGeneration') + .stub(SdkRepositoryContext.prototype, 'recordGeneration') .resolves(err({ kind: 'gitFailed', reason: 'git broke' })); const snapshotNotRecorded = sinon.stub(SdkWritePrompts.prototype, 'snapshotNotRecorded'); @@ -198,8 +196,8 @@ describe('SdkWriteAction', () => { expect(record().hasConflicts()).to.be.false; expect((await git.describe(repository.root(), record().pristineCommit())).parents).to.deep.equal([base]); const previous = (await git.resolveRef(repository.root(), repository.previousRef()))!; - const kept = (await store.readSnapshot(repository, previous))._unsafeUnwrap(); - expect(new TextDecoder().decode(kept.get('models/book.py'))).to.equal(DECORATED + MODEL_V1); + const kept = await snapshotFiles(repository.root(), previous); + expect(kept.get('models/book.py')).to.equal(DECORATED + MODEL_V1); }); it('writes markers where you and the generator changed the same lines, lists the file and fails', async () => { @@ -234,7 +232,7 @@ describe('SdkWriteAction', () => { const { repository, base } = await recorded({ 'README.md': '# sdk', 'models/book.py': MODEL_V1 }); write(sdk(), { 'models/book.py': MODEL_V1_EDITED }); sinon - .stub(PristineStoreService.prototype, 'stageConflicts') + .stub(SdkRepositoryContext.prototype, 'stageConflicts') .resolves(err({ kind: 'gitFailed', reason: 'index locked' })); const conflictsNotStaged = sinon.stub(SdkWritePrompts.prototype, 'conflictsNotStaged'); const conflicts = sinon.stub(SdkWritePrompts.prototype, 'conflicts'); @@ -252,7 +250,7 @@ describe('SdkWriteAction', () => { const { repository, base } = await recorded({ 'README.md': '# sdk' }); write(sdk(), { 'README.md': '# edited' }); sinon - .stub(PristineStoreService.prototype, 'savePrevious') + .stub(SdkRepositoryContext.prototype, 'savePrevious') .resolves(err({ kind: 'gitFailed', reason: 'git broke' })); const customCodeFailed = sinon.stub(SdkWritePrompts.prototype, 'customCodeFailed'); diff --git a/test/helpers/snapshots.ts b/test/helpers/snapshots.ts new file mode 100644 index 000000000..82943cea2 --- /dev/null +++ b/test/helpers/snapshots.ts @@ -0,0 +1,13 @@ +import { TextDecoder } from 'node:util'; +import { GitService } from '../../src/infrastructure/git-service.js'; +import { DirectoryPath } from '../../src/types/file/directoryPath.js'; +import { CommitId } from '../../src/types/sdk/commit-id.js'; + +/** The files a snapshot commit holds, by path below the SDK directory, as text. */ +export async function snapshotFiles(root: DirectoryPath, commit: CommitId): Promise> { + const git = new GitService(); + const entries = (await git.listTree(root, commit)).filter((entry) => entry.type === 'blob'); + const blobs = await git.readBlobs(root, [...new Set(entries.map((entry) => entry.oid))]); + const decoder = new TextDecoder(); + return new Map(entries.map((entry) => [entry.path.toString(), decoder.decode(blobs.get(entry.oid)!)])); +} diff --git a/test/prompts/sdk/write.test.ts b/test/prompts/sdk/write.test.ts index 97972c2ef..6128baed9 100644 --- a/test/prompts/sdk/write.test.ts +++ b/test/prompts/sdk/write.test.ts @@ -6,7 +6,7 @@ import { ConflictKind, MergePlan } from '../../../src/application/sdk-merge/merg import { SdkWritePrompts } from '../../../src/prompts/sdk/write.js'; import { DirectoryPath } from '../../../src/types/file/directoryPath.js'; import { RelativeFilePath } from '../../../src/types/file/relativeFilePath.js'; -import { SdkRepository } from '../../../src/types/sdk/sdk-repository.js'; +import { SdkRepositoryContext } from '../../../src/types/sdk-repository-context.js'; const rel = (value: string) => RelativeFilePath.fromGit(value); @@ -32,7 +32,7 @@ const plan = ( describe('SdkWritePrompts.sdkMerged', () => { const root = new DirectoryPath(process.cwd()); - const sdk = SdkRepository.create(root, root.join('sdk', 'typescript'))!; + const sdk = SdkRepositoryContext.create(root, root.join('sdk', 'typescript'))!; afterEach(() => { sinon.restore(); diff --git a/test/infrastructure/pristine-store-merge.test.ts b/test/types/sdk-repository-context-merge.test.ts similarity index 94% rename from test/infrastructure/pristine-store-merge.test.ts rename to test/types/sdk-repository-context-merge.test.ts index 713da9dbe..893b4f369 100644 --- a/test/infrastructure/pristine-store-merge.test.ts +++ b/test/types/sdk-repository-context-merge.test.ts @@ -6,7 +6,7 @@ import { Buffer } from 'node:buffer'; import { expect } from 'chai'; import { MergePlan } from '../../src/application/sdk-merge/merge-plan.js'; import { GitService } from '../../src/infrastructure/git-service.js'; -import { PristineStoreService } from '../../src/infrastructure/pristine-store-service.js'; +import { SdkContext } from '../../src/types/sdk-context.js'; import { DirectoryPath } from '../../src/types/file/directoryPath.js'; import { CodeGenerationVersion, Language, Stability } from '../../src/types/sdk/generate.js'; import { SdkGeneration } from '../../src/types/sdk/generation-record.js'; @@ -63,12 +63,10 @@ const counts = (plan: MergePlan) => const rel = (value: string) => RelativeFilePath.fromGit(value); -describe('PristineStoreService: the merge', () => { +describe('SdkRepositoryContext: the merge', () => { let root: string; let counter = 0; const git = new GitService(); - const store = new PristineStoreService(); - const service = store; const write = (directory: DirectoryPath, files: Files) => { for (const [relative, contents] of Object.entries(files)) { @@ -83,7 +81,7 @@ describe('PristineStoreService: the merge', () => { const sdk = project.join('sdk', 'typescript'); fs.mkdirSync(sdk.toString(), { recursive: true }); await git.init(project); - const repository = (await store.findRepository(sdk))!; + const repository = (await new SdkContext(Language.TYPESCRIPT, project.join('sdk')).repository())!; const baseDirectory = new DirectoryPath(path.join(root, `base-${counter}`)); const generatedDirectory = new DirectoryPath(path.join(root, `generated-${counter}`)); fs.mkdirSync(baseDirectory.toString()); @@ -92,17 +90,13 @@ describe('PristineStoreService: the merge', () => { write(sdk, disk); write(generatedDirectory, generated); const baseCommit = ( - await store.recordPristine( - repository, - (await store.snapshotGenerated(repository, baseDirectory))._unsafeUnwrap(), - TYPESCRIPT - ) + await repository.recordPristine((await repository.snapshotGenerated(baseDirectory))._unsafeUnwrap(), TYPESCRIPT) )._unsafeUnwrap(); - const diskTree = (await store.snapshotDisk(repository, baseCommit))._unsafeUnwrap(); - const generatedTree = (await store.snapshotGenerated(repository, generatedDirectory))._unsafeUnwrap(); + const diskTree = (await repository.snapshotDisk(baseCommit))._unsafeUnwrap(); + const generatedTree = (await repository.snapshotGenerated(generatedDirectory))._unsafeUnwrap(); - const { plan } = (await service.merge(repository, baseCommit, diskTree, generatedTree))._unsafeUnwrap(); - (await service.apply(repository, plan))._unsafeUnwrap(); + const { plan } = (await repository.merge(baseCommit, diskTree, generatedTree))._unsafeUnwrap(); + (await repository.apply(plan))._unsafeUnwrap(); const at = (relative: string) => path.join(sdk.toString(), relative); const list = (directory: string): string[] => @@ -126,7 +120,7 @@ describe('PristineStoreService: the merge', () => { }; beforeEach(() => { - root = fs.mkdtempSync(path.join(os.tmpdir(), 'pristine-store-merge-')); + root = fs.mkdtempSync(path.join(os.tmpdir(), 'sdk-repository-merge-')); }); afterEach(() => { diff --git a/test/infrastructure/pristine-store-service.test.ts b/test/types/sdk-repository-context.test.ts similarity index 65% rename from test/infrastructure/pristine-store-service.test.ts rename to test/types/sdk-repository-context.test.ts index 75e9c197b..ebc414d7a 100644 --- a/test/infrastructure/pristine-store-service.test.ts +++ b/test/types/sdk-repository-context.test.ts @@ -1,17 +1,17 @@ -import { RelativeFilePath } from '../../src/types/file/relativeFilePath.js'; import fs from 'fs'; import os from 'os'; import path from 'path'; -import { TextDecoder } from 'node:util'; import { expect } from 'chai'; import { GitService } from '../../src/infrastructure/git-service.js'; -import { PristineStoreService } from '../../src/infrastructure/pristine-store-service.js'; import { DirectoryPath } from '../../src/types/file/directoryPath.js'; +import { RelativeFilePath } from '../../src/types/file/relativeFilePath.js'; +import { SdkContext } from '../../src/types/sdk-context.js'; +import { SdkRepositoryContext } from '../../src/types/sdk-repository-context.js'; import { CommitId } from '../../src/types/sdk/commit-id.js'; import { CodeGenerationVersion, Language, Stability } from '../../src/types/sdk/generate.js'; import { GenerationRecord, SdkGeneration } from '../../src/types/sdk/generation-record.js'; -import { SdkRepository } from '../../src/types/sdk/sdk-repository.js'; import { SnapshotRef } from '../../src/types/sdk/snapshot-ref.js'; +import { snapshotFiles } from '../helpers/snapshots.js'; const TYPESCRIPT: SdkGeneration = { language: Language.TYPESCRIPT, @@ -21,9 +21,8 @@ const TYPESCRIPT: SdkGeneration = { const rel = (value: string) => RelativeFilePath.fromGit(value); -describe('PristineStoreService', () => { +describe('SdkRepositoryContext', () => { let root: string; - let service: PristineStoreService; const git = new GitService(); const write = (directory: DirectoryPath, relative: string, contents: string) => { @@ -40,35 +39,38 @@ describe('PristineStoreService', () => { return directory; }; - const record = async (repository: SdkRepository, files: Record) => { - const tree = (await service.snapshotGenerated(repository, generated(files)))._unsafeUnwrap(); - return service.recordPristine(repository, tree, TYPESCRIPT); + const record = async (repository: SdkRepositoryContext, files: Record) => { + const tree = (await repository.snapshotGenerated(generated(files)))._unsafeUnwrap(); + return repository.recordPristine(tree, TYPESCRIPT); }; - const project = async (sdk = path.join('sdk', 'typescript')): Promise => { + /** The context for an SDK directory whose language directory is `sdk`'s last segment, below `parent`. */ + const contextFor = (sdk: DirectoryPath) => new SdkContext(path.basename(sdk.toString()) as Language, sdk.join('..')); + + const project = async (sdk = path.join('sdk', 'typescript')): Promise => { const projectDirectory = new DirectoryPath(path.join(root, 'project')); fs.mkdirSync(projectDirectory.join(sdk).toString(), { recursive: true }); await git.init(projectDirectory); - return (await service.findRepository(projectDirectory.join(sdk)))!; + return (await contextFor(projectDirectory.join(sdk)).repository())!; }; - const text = (bytes: Uint8Array | undefined) => (bytes === undefined ? undefined : new TextDecoder().decode(bytes)); + const writeRecord = async (repository: SdkRepositoryContext, record: GenerationRecord) => + write(repository.sdkDirectory(), path.join('.apimatic', 'generation.json'), record.toJson()); beforeEach(() => { - root = fs.mkdtempSync(path.join(os.tmpdir(), 'pristine-store-')); - service = new PristineStoreService(); + root = fs.mkdtempSync(path.join(os.tmpdir(), 'sdk-repository-')); }); afterEach(() => { fs.rmSync(root, { recursive: true, force: true }); }); - describe('findRepository', () => { + describe('SdkContext.repository', () => { it('finds nothing outside a repository', async () => { const sdk = new DirectoryPath(path.join(root, 'sdk', 'typescript')); fs.mkdirSync(sdk.toString(), { recursive: true }); - expect(await service.findRepository(sdk)).to.be.undefined; + expect(await contextFor(sdk).repository()).to.be.undefined; }); it('answers for an SDK directory that is not there yet, from its nearest existing ancestor', async () => { @@ -76,7 +78,7 @@ describe('PristineStoreService', () => { fs.mkdirSync(projectDirectory.toString()); await git.init(projectDirectory); - const repository = (await service.findRepository(projectDirectory.join('sdk', 'typescript')))!; + const repository = (await contextFor(projectDirectory.join('sdk', 'typescript')).repository())!; expect(repository.root().isEqual(projectDirectory)).to.be.true; expect(repository.pristineRef().toString()).to.equal('refs/apimatic/pristine/sdk/typescript'); @@ -91,11 +93,11 @@ describe('PristineStoreService', () => { }); }); - describe('initRepository', () => { + describe('SdkContext.initRepository', () => { it('creates the SDK directory when it is not there yet', async () => { const sdk = new DirectoryPath(path.join(root, 'sdk', 'typescript')); - const repository = (await service.initRepository(sdk))._unsafeUnwrap(); + const repository = (await contextFor(sdk).initRepository())._unsafeUnwrap(); expect(repository.isRootSdk()).to.be.true; expect(fs.existsSync(path.join(sdk.toString(), '.git'))).to.be.true; @@ -105,7 +107,7 @@ describe('PristineStoreService', () => { const sdk = new DirectoryPath(path.join(root, 'sdk', 'typescript')); write(sdk, 'README.md', '# sdk'); - const repository = (await service.initRepository(sdk))._unsafeUnwrap(); + const repository = (await contextFor(sdk).initRepository())._unsafeUnwrap(); expect(repository.isRootSdk()).to.be.true; expect(repository.pristineRef().toString()).to.equal('refs/apimatic/pristine'); @@ -135,9 +137,9 @@ describe('PristineStoreService', () => { authorName: 'APIMatic CLI', subject: 'apimatic: pristine typescript v4 stable' }); - const files = (await service.readSnapshot(repository, commit))._unsafeUnwrap(); + const files = await snapshotFiles(repository.root(), commit); expect([...files.keys()]).to.have.members(['README.md', 'src/index.ts', '.gitignore', 'dist/x.js']); - expect(text(files.get('src/index.ts'))).to.equal('export {};'); + expect(files.get('src/index.ts')).to.equal('export {};'); }); it('chains the next snapshot onto the last and moves the ref', async () => { @@ -147,8 +149,8 @@ describe('PristineStoreService', () => { const second = (await record(repository, { 'README.md': '# v2' }))._unsafeUnwrap(); expect((await git.describe(repository.root(), second)).parents).to.deep.equal([first]); - expect((await service.pristineTip(repository))?.isEqual(second)).to.be.true; - expect(text((await service.readSnapshot(repository, first))._unsafeUnwrap().get('README.md'))).to.equal('# v1'); + expect((await git.resolveRef(repository.root(), repository.pristineRef()))?.isEqual(second)).to.be.true; + expect((await snapshotFiles(repository.root(), first)).get('README.md')).to.equal('# v1'); }); it('keeps the last snapshot when the generation is identical, so the record names the same commit', async () => { @@ -166,26 +168,26 @@ describe('PristineStoreService', () => { const outer = await project(); (await record(outer, { 'README.md': '# outer' }))._unsafeUnwrap(); fs.mkdirSync(outer.sdkDirectory().join('typescript').toString()); - const inner = (await service.findRepository(outer.sdkDirectory().join('typescript')))!; + const inner = (await contextFor(outer.sdkDirectory().join('typescript')).repository())!; expect((await record(inner, { 'README.md': '# inner' }))._unsafeUnwrapErr().kind).to.equal('nestedSdkDirectory'); const other = await project(path.join('other', 'typescript', 'nested')); (await record(other, { 'README.md': '# nested' }))._unsafeUnwrap(); - const parent = (await service.findRepository( + const parent = (await contextFor( new DirectoryPath(path.join(root, 'project', 'other', 'typescript')) - ))!; + ).repository())!; expect((await record(parent, {}))._unsafeUnwrapErr().kind).to.equal('nestedSdkDirectory'); }); it('refuses any SDK directory below a repository whose root is one', async () => { const sdk = new DirectoryPath(path.join(root, 'sdk')); fs.mkdirSync(sdk.toString(), { recursive: true }); - const repository = (await service.initRepository(sdk))._unsafeUnwrap(); + const repository = (await contextFor(sdk).initRepository())._unsafeUnwrap(); (await record(repository, { 'README.md': '# root' }))._unsafeUnwrap(); expect(await git.resolveRef(sdk, SnapshotRef.pristine(''))).to.not.be.undefined; fs.mkdirSync(sdk.join('typescript').toString()); - const below = (await service.findRepository(sdk.join('typescript')))!; + const below = (await contextFor(sdk.join('typescript')).repository())!; expect((await record(below, {}))._unsafeUnwrapErr().kind).to.equal('nestedSdkDirectory'); }); @@ -196,20 +198,18 @@ describe('PristineStoreService', () => { const sdk = new DirectoryPath(path.join(root, 'sdk', 'typescript')); write(sdk, 'README.md', '# edited'); write(sdk, path.join('.apimatic', 'generation.json'), '{}'); - const repository = (await service.initRepository(sdk))._unsafeUnwrap(); + const repository = (await contextFor(sdk).initRepository())._unsafeUnwrap(); - const first = (await service.savePrevious(repository, undefined))._unsafeUnwrap(); + const first = (await repository.savePrevious(undefined))._unsafeUnwrap(); write(sdk, 'README.md', '# edited again'); - const second = (await service.savePrevious(repository, undefined))._unsafeUnwrap(); + const second = (await repository.savePrevious(undefined))._unsafeUnwrap(); - const kept = (await service.readSnapshot(repository, second))._unsafeUnwrap(); + const kept = await snapshotFiles(sdk, second); expect([...kept.keys()]).to.have.members(['README.md', '.apimatic/generation.json']); - expect(text(kept.get('README.md'))).to.equal('# edited again'); + expect(kept.get('README.md')).to.equal('# edited again'); expect((await git.resolveRef(sdk, repository.previousRef()))?.isEqual(second)).to.be.true; expect((await git.describe(sdk, second)).parents).to.be.empty; - expect(text((await service.readSnapshot(repository, first))._unsafeUnwrap().get('README.md'))).to.equal( - '# edited' - ); + expect((await snapshotFiles(sdk, first)).get('README.md')).to.equal('# edited'); }); it('includes a generated file the SDK’s own .gitignore matches, and leaves other ignored files out', async () => { @@ -222,93 +222,60 @@ describe('PristineStoreService', () => { write(sdk, 'dist/generated.js', 'generated'); write(sdk, 'dist/built.js', 'built'); - const previous = (await service.savePrevious(repository, base))._unsafeUnwrap(); + const previous = (await repository.savePrevious(base))._unsafeUnwrap(); - expect([...(await service.readSnapshot(repository, previous))._unsafeUnwrap().keys()]).to.have.members([ + expect([...(await snapshotFiles(repository.root(), previous)).keys()]).to.have.members([ '.gitignore', 'dist/generated.js' ]); }); }); - describe('readSnapshot', () => { - it('reports a commit the repository does not hold', async () => { - const repository = await project(); - - const missing = await service.readSnapshot(repository, CommitId.create('0'.repeat(40))!); - - expect(missing._unsafeUnwrapErr().kind).to.equal('snapshotMissing'); - }); - }); - describe('the generation record', () => { it('is absent until written, then reads back what was written', async () => { const repository = await project(); - const sdk = repository.sdkDirectory(); - expect(await service.readRecord(sdk)).to.be.undefined; - const pristine = (await record(repository, { 'README.md': '# v1' }))._unsafeUnwrap(); - const written = GenerationRecord.written(TYPESCRIPT, pristine, new Date('2026-10-20T10:00:00Z'), '2.3.0', [ - 'src/index.ts' - ].map(rel)); + expect(await repository.readRecord()).to.be.undefined; + const tree = (await repository.snapshotGenerated(generated({ 'README.md': '# v1' })))._unsafeUnwrap(); - const file = (await service.writeRecord(sdk, written))._unsafeUnwrap(); + const recorded = ( + await repository.recordGeneration(tree, TYPESCRIPT, '2.3.0', ['src/index.ts'].map(rel)) + )._unsafeUnwrap(); - expect(file.toString()).to.equal(path.join(sdk.toString(), '.apimatic', 'generation.json')); - const read = (await service.readRecord(sdk))!; - expect(read.pristineCommit().isEqual(pristine)).to.be.true; + expect(fs.existsSync(path.join(repository.sdkDirectory().toString(), '.apimatic', 'generation.json'))).to.be.true; + const read = (await repository.readRecord())!; + expect(read.toJson()).to.equal(recorded.toJson()); expect(read.conflictedPaths().map(String)).to.deep.equal(['src/index.ts']); expect(read.isFor(TYPESCRIPT)).to.be.true; - expect(read.toJson()).to.equal(written.toJson()); + expect((await git.resolveRef(repository.root(), repository.pristineRef()))?.isEqual(read.pristineCommit())).to.be + .true; }); it('reads as absent when the file cannot be understood', async () => { const repository = await project(); write(repository.sdkDirectory(), path.join('.apimatic', 'generation.json'), '{"schemaVersion": 2}'); - expect(await service.readRecord(repository.sdkDirectory())).to.be.undefined; - }); - - it('is written after the snapshot it names, in one step', async () => { - const repository = await project(); - const tree = (await service.snapshotGenerated(repository, generated({ 'README.md': '# v1' })))._unsafeUnwrap(); - - const recorded = ( - await service.recordGeneration(repository, tree, TYPESCRIPT, '2.3.0', ['src/index.ts'].map(rel)) - )._unsafeUnwrap(); - - const read = (await service.readRecord(repository.sdkDirectory()))!; - expect(read.toJson()).to.equal(recorded.toJson()); - expect((await service.pristineTip(repository))?.isEqual(read.pristineCommit())).to.be.true; + expect(await repository.readRecord()).to.be.undefined; }); }); describe('inspect', () => { const MARKED = 'one\n<<<<<<< Your changes\nmine\n=======\ntheirs\n>>>>>>> Generated by APIMatic\n'; - it('finds no repository', async () => { - const sdk = new DirectoryPath(path.join(root, 'sdk', 'typescript')); - - expect((await service.inspect(sdk, TYPESCRIPT))._unsafeUnwrap()).to.deep.equal({ kind: 'noRepository' }); - }); - - it('finds a repository but no record', async () => { + it('finds no record', async () => { const repository = await project(); - const state = (await service.inspect(repository.sdkDirectory(), TYPESCRIPT))._unsafeUnwrap(); - - expect(state.kind).to.equal('noRecord'); - expect(state.kind === 'noRecord' && state.repository.root().isEqual(repository.root())).to.be.true; + expect((await repository.inspect(TYPESCRIPT))._unsafeUnwrap()).to.deep.equal({ kind: 'noRecord' }); }); it('finds a record for another generation', async () => { const repository = await project(); const pristine = (await record(repository, { 'README.md': '# v1' }))._unsafeUnwrap(); - await service.writeRecord( - repository.sdkDirectory(), + await writeRecord( + repository, GenerationRecord.written({ ...TYPESCRIPT, stability: Stability.BETA }, pristine, new Date(), '2.3.0') ); - const state = (await service.inspect(repository.sdkDirectory(), TYPESCRIPT))._unsafeUnwrap(); + const state = (await repository.inspect(TYPESCRIPT))._unsafeUnwrap(); expect(state.kind).to.equal('otherGeneration'); expect(state.kind === 'otherGeneration' && state.recorded.stability).to.equal(Stability.BETA); @@ -317,12 +284,9 @@ describe('PristineStoreService', () => { it('finds a record whose snapshot the repository does not hold', async () => { const repository = await project(); const missing = CommitId.create('f'.repeat(40))!; - await service.writeRecord( - repository.sdkDirectory(), - GenerationRecord.written(TYPESCRIPT, missing, new Date(), '2.3.0') - ); + await writeRecord(repository, GenerationRecord.written(TYPESCRIPT, missing, new Date(), '2.3.0')); - const state = (await service.inspect(repository.sdkDirectory(), TYPESCRIPT))._unsafeUnwrap(); + const state = (await repository.inspect(TYPESCRIPT))._unsafeUnwrap(); expect(state.kind).to.equal('snapshotMissing'); expect(state.kind === 'snapshotMissing' && state.commit.isEqual(missing)).to.be.true; @@ -332,14 +296,20 @@ describe('PristineStoreService', () => { const repository = await project(); const sdk = repository.sdkDirectory(); const pristine = (await record(repository, { 'README.md': '# v1' }))._unsafeUnwrap(); - await service.writeRecord( - sdk, - GenerationRecord.written(TYPESCRIPT, pristine, new Date(), '2.3.0', ['src/a.ts', 'src/b.ts', 'src/gone.ts'].map(rel)) + await writeRecord( + repository, + GenerationRecord.written( + TYPESCRIPT, + pristine, + new Date(), + '2.3.0', + ['src/a.ts', 'src/b.ts', 'src/gone.ts'].map(rel) + ) ); write(sdk, 'src/a.ts', MARKED); write(sdk, 'src/b.ts', 'resolved\n'); - const state = (await service.inspect(sdk, TYPESCRIPT))._unsafeUnwrap(); + const state = (await repository.inspect(TYPESCRIPT))._unsafeUnwrap(); expect(state).to.include({ kind: 'unresolved' }); expect(state.kind === 'unresolved' && state.paths.map(String)).to.deep.equal(['src/a.ts']); @@ -347,12 +317,14 @@ describe('PristineStoreService', () => { it('is ready to merge against the recorded snapshot once the markers are gone', async () => { const repository = await project(); - const sdk = repository.sdkDirectory(); const pristine = (await record(repository, { 'README.md': '# v1' }))._unsafeUnwrap(); - await service.writeRecord(sdk, GenerationRecord.written(TYPESCRIPT, pristine, new Date(), '2.3.0', ['src/a.ts'].map(rel))); - write(sdk, 'src/a.ts', 'resolved\n'); + await writeRecord( + repository, + GenerationRecord.written(TYPESCRIPT, pristine, new Date(), '2.3.0', ['src/a.ts'].map(rel)) + ); + write(repository.sdkDirectory(), 'src/a.ts', 'resolved\n'); - const state = (await service.inspect(sdk, TYPESCRIPT))._unsafeUnwrap(); + const state = (await repository.inspect(TYPESCRIPT))._unsafeUnwrap(); expect(state.kind).to.equal('ready'); expect(state.kind === 'ready' && state.base.isEqual(pristine)).to.be.true; @@ -361,10 +333,9 @@ describe('PristineStoreService', () => { it('refuses an SDK directory nested in one that has snapshots', async () => { const outer = await project(); (await record(outer, { 'README.md': '# outer' }))._unsafeUnwrap(); + const inner = (await contextFor(outer.sdkDirectory().join('typescript')).repository())!; - const problem = (await service.inspect(outer.sdkDirectory().join('typescript'), TYPESCRIPT))._unsafeUnwrapErr(); - - expect(problem.kind).to.equal('nestedSdkDirectory'); + expect((await inner.inspect(TYPESCRIPT))._unsafeUnwrapErr().kind).to.equal('nestedSdkDirectory'); }); }); }); From 8e9e9bf19a677d1cc6c17eb299587d21c728ead3 Mon Sep 17 00:00:00 2001 From: Asad Ali <14asadali@gmail.com> Date: Wed, 21 Oct 2026 11:15:53 +0500 Subject: [PATCH 11/29] refactor(sdk): fold the merge layer into types and the git service src/application/ held four files with one caller, and both Types and Infrastructure imported from it, against the layer order. MergePlan and its types move to src/types/sdk/merge-plan.ts, with planMerge as MergePlan.from, and the conflict markers move beside it. GitService reads merge-tree's output itself, as diffTrees and listTree already do, and diffTrees returns the shared Change type. Co-Authored-By: Claude Opus 5.5 --- .ai/plans/custom-code.md | 6 +- src/actions/sdk/generate.ts | 2 +- src/actions/sdk/write.ts | 2 +- src/application/sdk-merge/merge-plan.ts | 94 ------- .../sdk-merge/merge-tree-output.ts | 37 --- src/application/sdk-merge/sdk-merge.ts | 156 ----------- src/infrastructure/git-service.ts | 47 ++-- src/prompts/sdk/write.ts | 2 +- src/types/sdk-repository-context.ts | 18 +- .../sdk/conflict-markers.ts} | 0 src/types/sdk/merge-plan.ts | 259 ++++++++++++++++++ test/actions/sdk/write.test.ts | 2 +- .../sdk-merge/merge-tree-output.test.ts | 66 ----- test/infrastructure/git-service.test.ts | 52 ++++ test/prompts/sdk/write.test.ts | 2 +- .../sdk-repository-context-merge.test.ts | 2 +- .../sdk/conflict-markers.test.ts} | 2 +- .../sdk/merge-plan.test.ts} | 33 ++- 18 files changed, 377 insertions(+), 405 deletions(-) delete mode 100644 src/application/sdk-merge/merge-plan.ts delete mode 100644 src/application/sdk-merge/merge-tree-output.ts delete mode 100644 src/application/sdk-merge/sdk-merge.ts rename src/{application/sdk-merge/markers.ts => types/sdk/conflict-markers.ts} (100%) create mode 100644 src/types/sdk/merge-plan.ts delete mode 100644 test/application/sdk-merge/merge-tree-output.test.ts rename test/{application/sdk-merge/markers.test.ts => types/sdk/conflict-markers.test.ts} (97%) rename test/{application/sdk-merge/sdk-merge.test.ts => types/sdk/merge-plan.test.ts} (84%) diff --git a/.ai/plans/custom-code.md b/.ai/plans/custom-code.md index 4f338f44c..e5059dc7a 100644 --- a/.ai/plans/custom-code.md +++ b/.ai/plans/custom-code.md @@ -283,7 +283,7 @@ a bare remote) and `test/actions/sdk/write.test.ts`. | Phase | Commit | What | |---|---|---| | 1 | `db3fe9f2`, reworked in `185e0663` | `GitService` (the system git through `execa`, every call with the 3.3 settings) and the pristine store (since the `SdkRepositoryContext`) with the value objects: refs, commit and tree ids, git version, generation record. | -| 2 | `185e0663` | The merge (since moved onto `SdkRepositoryContext`): the temporary commits, `merge-tree --write-tree -z`, the diffs; `src/application/sdk-merge/` keeps only what is pure: parsing merge-tree's output, the conflict kinds, marker relabelling, `MergePlan`. | +| 2 | `185e0663` | The merge (since moved onto `SdkRepositoryContext`): the temporary commits, `merge-tree --write-tree -z`, the diffs; `src/application/sdk-merge/` (since folded into `GitService` and `src/types/sdk/`) keeps only what is pure: parsing merge-tree's output, the conflict kinds, marker relabelling, `MergePlan`. | | 3 | `4d5b9ccf` | `SdkPrepareAction`, `SdkDestination`, `SdkWriteAction`, their prompts, the repository's `inspect`; `GenerateAction` back to validation and generation; `SdkContext.save` keeps `.git`. | | 4, 5 | `87453917` | The missing-snapshot message with the push and fetch commands; `stageConflicts` through `update-index --index-info`; `SdkMergeConflictedEvent`. | | 6 | this file | `CONTEXT.md` terms, the README section and requirement, the command description. | @@ -294,9 +294,9 @@ PR into `dev`, squash-merged, title `feat(sdk): keep your edits when regeneratin | File | Role | |---|---| -| `src/infrastructure/git-service.ts` | the system git behind every snapshot, the merge and the index staging | +| `src/infrastructure/git-service.ts` | the system git behind every snapshot, the merge (its output read into stages and messages) and the index staging | | `src/types/sdk-repository-context.ts` | the SDK directory with its repository: snapshots, the record, `inspect`, the merge, its application to disk, the staging | -| `src/application/sdk-merge/*.ts` | pure: merge-tree output, conflict kinds, markers, `MergePlan` | +| `src/types/sdk/{merge-plan,conflict-markers}.ts` | pure: `MergePlan` built from git's merge and diffs, conflict kinds, markers | | `src/types/sdk/{snapshot-ref,commit-id,tree-id,git-version,generation-record,sdk-destination}.ts`, `src/types/file/relativeFilePath.ts` | value objects | | `src/actions/sdk/{generate,prepare,write}.ts`, `src/prompts/sdk/{generate,prepare,write}.ts` | the run | | `src/types/sdk-context.ts` | `directory()`, `repository()`, `initRepository()`, save keeps `.git` | diff --git a/src/actions/sdk/generate.ts b/src/actions/sdk/generate.ts index b2b930647..173af170b 100644 --- a/src/actions/sdk/generate.ts +++ b/src/actions/sdk/generate.ts @@ -1,4 +1,3 @@ -import { MergePlan } from '../../application/sdk-merge/merge-plan.js'; import { SdkGenerationService } from '../../infrastructure/services/sdk-generation-service.js'; import { DirectoryPath } from '../../types/file/directoryPath.js'; import { ActionResult } from '../action-result.js'; @@ -9,6 +8,7 @@ import { TempContext } from '../../types/temp-context.js'; import { codegenVersionFor, isAvailableLanguage, Language, Stability } from '../../types/sdk/generate.js'; import { ProjectContext } from '../../types/project-context.js'; import { SdkGeneration } from '../../types/sdk/generation-record.js'; +import { MergePlan } from '../../types/sdk/merge-plan.js'; import { SdkPrepareAction } from './prepare.js'; import { SdkWriteAction } from './write.js'; diff --git a/src/actions/sdk/write.ts b/src/actions/sdk/write.ts index 979ba851b..8d6b441ba 100644 --- a/src/actions/sdk/write.ts +++ b/src/actions/sdk/write.ts @@ -1,5 +1,4 @@ import { err, ok, Result } from 'neverthrow'; -import { MergePlan } from '../../application/sdk-merge/merge-plan.js'; import { envInfo } from '../../infrastructure/env-info.js'; import { SdkWritePrompts } from '../../prompts/sdk/write.js'; import { DirectoryPath } from '../../types/file/directoryPath.js'; @@ -8,6 +7,7 @@ import { SdkContext } from '../../types/sdk-context.js'; import { SdkRepositoryContext, SdkRepositoryProblem } from '../../types/sdk-repository-context.js'; import { CommitId } from '../../types/sdk/commit-id.js'; import { SdkGeneration } from '../../types/sdk/generation-record.js'; +import { MergePlan } from '../../types/sdk/merge-plan.js'; import { SdkDestination } from '../../types/sdk/sdk-destination.js'; import { TreeId } from '../../types/sdk/tree-id.js'; import { ActionResult } from '../action-result.js'; diff --git a/src/application/sdk-merge/merge-plan.ts b/src/application/sdk-merge/merge-plan.ts deleted file mode 100644 index e2e7c0ae6..000000000 --- a/src/application/sdk-merge/merge-plan.ts +++ /dev/null @@ -1,94 +0,0 @@ -import { RelativeFilePath } from '../../types/file/relativeFilePath.js'; - -export type ConflictKind = - /** You and the generator changed the same lines; the file holds markers. */ - | 'bothChanged' - /** You and the generator both added the path with different content; the file holds markers. */ - | 'bothAdded' - /** You deleted the file and the generator changed it; the new version is written back. */ - | 'deletedByYou' - /** You edited the file and the generator no longer produces it; your file stays. */ - | 'noLongerGenerated' - /** You and the generator both changed a binary file; yours stays. */ - | 'binary' - /** A shape git reports that fits none of the above, such as both sides renaming one file differently; `message` is git's. */ - | 'other'; - -/** A file to write, as the blob the merged tree holds for it. */ -export type FileWrite = { path: RelativeFilePath; blob: string }; - -export type Conflict = { path: RelativeFilePath; kind: ConflictKind; message?: string }; - -export type Rename = { from: RelativeFilePath; to: RelativeFilePath; by: 'generator' | 'you' }; - -/** Every path the run touched or left alone, by what happened to it; conflicted paths are listed under `conflicts` instead. */ -export type MergeFiles = { - /** Files the generator produced for the first time. */ - added: readonly RelativeFilePath[]; - /** Unedited files replaced by their new version, or moved to the generator's new name. */ - updated: readonly RelativeFilePath[]; - /** Edited files the generator also changed, merged without conflict. */ - merged: readonly RelativeFilePath[]; - /** Unedited files the generator stopped producing. */ - removed: readonly RelativeFilePath[]; - /** Edited files the generator left alone. */ - kept: readonly RelativeFilePath[]; - /** Files neither side changed. */ - unchanged: readonly RelativeFilePath[]; -}; - -/** - * What a run writes and removes in the SDK directory, by path below it with `/` between segments. - * A path the plan does not mention is left as it is. - */ -export class MergePlan { - public readonly writes: readonly FileWrite[]; - public readonly deletes: readonly RelativeFilePath[]; - public readonly conflicts: readonly Conflict[]; - public readonly renames: readonly Rename[]; - public readonly files: MergeFiles; - - constructor( - writes: readonly FileWrite[], - deletes: readonly RelativeFilePath[], - conflicts: readonly Conflict[], - renames: readonly Rename[], - files: MergeFiles - ) { - this.writes = writes; - this.deletes = deletes; - this.conflicts = conflicts; - this.renames = renames; - this.files = files; - } - - public hasConflicts(): boolean { - return this.conflicts.length > 0; - } - - public conflictedPaths(): RelativeFilePath[] { - return this.conflicts.map((conflict) => conflict.path); - } - - public conflictsByKind(): Record { - const counts: Record = { - bothChanged: 0, - bothAdded: 0, - deletedByYou: 0, - noLongerGenerated: 0, - binary: 0, - other: 0 - }; - for (const conflict of this.conflicts) { - counts[conflict.kind] += 1; - } - return counts; - } - - /** Whether the file at `path` is written with conflict markers in it. */ - public hasMarkers(path: RelativeFilePath): boolean { - return this.conflicts.some( - (conflict) => conflict.path.isEqual(path) && (conflict.kind === 'bothChanged' || conflict.kind === 'bothAdded') - ); - } -} diff --git a/src/application/sdk-merge/merge-tree-output.ts b/src/application/sdk-merge/merge-tree-output.ts deleted file mode 100644 index 6fe90ec84..000000000 --- a/src/application/sdk-merge/merge-tree-output.ts +++ /dev/null @@ -1,37 +0,0 @@ -import { TextDecoder } from 'node:util'; -import { RelativeFilePath } from '../../types/file/relativeFilePath.js'; - -/** One conflicted path's version on one side: stage 1 is the base, 2 yours, 3 the generator's. */ -export type StageEntry = { mode: string; oid: string; stage: 1 | 2 | 3; path: RelativeFilePath }; - -/** One of git's informational messages, such as `CONFLICT (modify/delete)` for a path. */ -export type MergeMessage = { paths: RelativeFilePath[]; type: string; message: string }; - -export type MergeTreeOutput = { tree: string; stages: StageEntry[]; messages: MergeMessage[] }; - -/** - * Reads what `git merge-tree --write-tree -z` prints: the merged tree's id, then one entry per - * stage of every conflicted path, then, after an empty entry, the messages git would have shown - * during a merge, each as a count, that many paths, a type and the text. - */ -export function parseMergeTreeOutput(output: Uint8Array): MergeTreeOutput { - const fields = new TextDecoder().decode(output).split('\0'); - const tree = fields[0].trim(); - const stages: StageEntry[] = []; - let index = 1; - for (; index < fields.length && fields[index] !== ''; index += 1) { - const [meta, path] = fields[index].split('\t'); - const [mode, oid, stage] = meta.split(' '); - stages.push({ mode, oid, stage: Number(stage) as StageEntry['stage'], path: RelativeFilePath.fromGit(path) }); - } - const messages: MergeMessage[] = []; - for (index += 1; index < fields.length && fields[index] !== ''; ) { - const count = Number(fields[index]); - const paths = fields.slice(index + 1, index + 1 + count).map((field) => RelativeFilePath.fromGit(field)); - const type = fields[index + 1 + count]; - const message = fields[index + 2 + count]; - messages.push({ paths, type, message: message.trim() }); - index += 3 + count; - } - return { tree, stages, messages }; -} diff --git a/src/application/sdk-merge/sdk-merge.ts b/src/application/sdk-merge/sdk-merge.ts deleted file mode 100644 index 7ab3b397f..000000000 --- a/src/application/sdk-merge/sdk-merge.ts +++ /dev/null @@ -1,156 +0,0 @@ -import { RelativeFilePath } from '../../types/file/relativeFilePath.js'; -import { Conflict, ConflictKind, FileWrite, MergeFiles, MergePlan, Rename } from './merge-plan.js'; -import { MergeTreeOutput } from './merge-tree-output.js'; - -/** How a path changed between two trees, as `diff-tree` reports it. */ -export type Change = { - status: 'A' | 'M' | 'D' | 'T' | 'R' | 'C'; - path: RelativeFilePath; - from?: RelativeFilePath; - blob?: string; -}; - -export type MergeInputs = { - merge: MergeTreeOutput; - /** Base to the disk: what you changed. Renames paired. */ - yours: readonly Change[]; - /** Base to the fresh generation: what the generator changed. Renames paired. */ - generated: readonly Change[]; - /** Disk to the merged tree: what the run writes and removes. No renames. */ - applied: readonly Change[]; - /** Every path the base holds. */ - basePaths: readonly RelativeFilePath[]; -}; - -const CONFLICT_PREFIX = 'CONFLICT ('; - -/** The stronger of two accounts of one path, when git reports it more than once. */ -const RANK: Record = { - binary: 5, - bothAdded: 4, - deletedByYou: 3, - noLongerGenerated: 3, - bothChanged: 2, - other: 1 -}; - -function kindOf(type: string, message: string, stages: ReadonlySet): ConflictKind { - if (type === 'CONFLICT (binary)') { - return 'binary'; - } - if (message.startsWith('CONFLICT (add/add)')) { - return 'bothAdded'; - } - if (type === 'CONFLICT (modify/delete)' || type === 'CONFLICT (rename/delete)') { - return stages.has(2) ? 'noLongerGenerated' : 'deletedByYou'; - } - if (type === 'CONFLICT (contents)') { - return 'bothChanged'; - } - return 'other'; -} - -function conflictsOf(merge: MergeTreeOutput): Conflict[] { - const stagesByPath = new Map>(); - for (const entry of merge.stages) { - const key = entry.path.toString(); - stagesByPath.set(key, (stagesByPath.get(key) ?? new Set()).add(entry.stage)); - } - const byPath = new Map(); - for (const { paths, type, message } of merge.messages) { - if (!type.startsWith(CONFLICT_PREFIX)) { - continue; - } - for (const path of paths) { - const key = path.toString(); - const kind = kindOf(type, message, stagesByPath.get(key) ?? new Set()); - const known = byPath.get(key); - if (known === undefined || RANK[kind] > RANK[known.kind]) { - byPath.set(key, kind === 'other' ? { path, kind, message } : { path, kind }); - } - } - } - return [...byPath.values()]; -} - -/** Each change under every path it names, so a rename is found by either name. */ -const changedPaths = (changes: readonly Change[]): Map => - new Map( - changes.flatMap((change): [string, Change][] => - change.from === undefined - ? [[change.path.toString(), change]] - : [ - [change.from.toString(), change], - [change.path.toString(), change] - ] - ) - ); - -/** - * What git's merge means for the SDK directory: the writes and deletions that bring the disk - * to the merged tree, the conflicts by kind, the renames each side made, and every other path - * by what happened to it. - */ -export function planMerge(inputs: MergeInputs): MergePlan { - const conflicts = conflictsOf(inputs.merge); - const conflicted = new Set(conflicts.map((conflict) => conflict.path.toString())); - const yours = changedPaths(inputs.yours); - const generated = changedPaths(inputs.generated); - const base = new Set(inputs.basePaths.map((path) => path.toString())); - - const writes: FileWrite[] = []; - const deletes: RelativeFilePath[] = []; - const files: Record = { - added: [], - updated: [], - merged: [], - removed: [], - kept: [], - unchanged: [] - }; - - for (const change of inputs.applied) { - const key = change.path.toString(); - if (change.status === 'D') { - deletes.push(change.path); - if (!conflicted.has(key)) { - files.removed.push(change.path); - } - continue; - } - writes.push({ path: change.path, blob: change.blob! }); - if (conflicted.has(key)) { - continue; - } - if (!base.has(key) && generated.get(key)?.status === 'A') { - files.added.push(change.path); - } else if (yours.get(key)?.status === 'M' && generated.has(key)) { - files.merged.push(change.path); - } else { - files.updated.push(change.path); - } - } - - for (const [key, change] of yours) { - if (change.status === 'M' && !generated.has(key) && !conflicted.has(key)) { - files.kept.push(change.path); - } - } - for (const path of inputs.basePaths) { - const key = path.toString(); - if (!yours.has(key) && !generated.has(key)) { - files.unchanged.push(path); - } - } - - const renames: Rename[] = [ - ...inputs.generated - .filter((change) => change.status === 'R') - .map((change) => ({ from: change.from!, to: change.path, by: 'generator' as const })), - ...inputs.yours - .filter((change) => change.status === 'R') - .map((change) => ({ from: change.from!, to: change.path, by: 'you' as const })) - ]; - - return new MergePlan(writes, deletes, conflicts, renames, files); -} diff --git a/src/infrastructure/git-service.ts b/src/infrastructure/git-service.ts index 0177c585b..932bc704b 100644 --- a/src/infrastructure/git-service.ts +++ b/src/infrastructure/git-service.ts @@ -1,13 +1,14 @@ import { Buffer } from 'node:buffer'; import fs from 'node:fs'; +import { TextDecoder } from 'node:util'; import { execa, ExecaError } from 'execa'; -import { StageEntry } from '../application/sdk-merge/merge-tree-output.js'; import { RelativeFilePath } from '../types/file/relativeFilePath.js'; import { DirectoryPath } from '../types/file/directoryPath.js'; import { FileName } from '../types/file/fileName.js'; import { FilePath } from '../types/file/filePath.js'; import { CommitId } from '../types/sdk/commit-id.js'; import { GitVersion } from '../types/sdk/git-version.js'; +import { Change, MergeMessage, MergeTreeOutput, StageEntry } from '../types/sdk/merge-plan.js'; import { SnapshotRef } from '../types/sdk/snapshot-ref.js'; import { TreeId } from '../types/sdk/tree-id.js'; import { errorMessage } from '../utils/error-utils.js'; @@ -15,18 +16,6 @@ import { withDirPath } from './tmp-extensions.js'; export type TreeEntry = { mode: string; type: 'blob' | 'tree' | 'commit'; oid: string; path: RelativeFilePath }; -/** One line of `diff-tree --raw`: how `path` differs between two trees, and the blob it holds in the second. */ -export type DiffEntry = { - status: 'A' | 'M' | 'D' | 'T' | 'R' | 'C'; - path: RelativeFilePath; - /** The path in the first tree, for a rename or copy. */ - from?: RelativeFilePath; - /** The blob in the second tree; absent for a deletion. */ - blob?: string; -}; - -export type MergeTreeRun = { conflicted: boolean; output: Uint8Array }; - export type CommitDescription = { parents: CommitId[]; authorName: string; subject: string }; /** What `git` reported when a command failed. */ @@ -191,10 +180,11 @@ export class GitService { } /** - * `git merge-tree --write-tree` of two commits that share a parent: the merged tree and git's - * account of the conflicts, in its `-z` form, for the application layer to read. + * `git merge-tree --write-tree -z` of two commits that share a parent. It prints the merged + * tree's id, then one entry per stage of every conflicted path, then, after an empty entry, the + * messages git would have shown during a merge, each as a count, that many paths, a type and the text. */ - public async mergeTree(root: DirectoryPath, ours: CommitId, theirs: CommitId): Promise { + public async mergeTree(root: DirectoryPath, ours: CommitId, theirs: CommitId): Promise { return withDirPath(async (temporary) => { const result = await this.run( root, @@ -205,7 +195,24 @@ export class GitService { 'buffer', [0, 1] ); - return { conflicted: result.exitCode === 1, output: new Uint8Array(result.stdout) }; + const fields = new TextDecoder().decode(new Uint8Array(result.stdout)).split('\0'); + const stages: StageEntry[] = []; + let index = 1; + for (; index < fields.length && fields[index] !== ''; index += 1) { + const [meta, path] = fields[index].split('\t'); + const [mode, oid, stage] = meta.split(' '); + stages.push({ mode, oid, stage: Number(stage) as StageEntry['stage'], path: RelativeFilePath.fromGit(path) }); + } + const messages: MergeMessage[] = []; + for (index += 1; index < fields.length && fields[index] !== ''; ) { + const count = Number(fields[index]); + const paths = fields.slice(index + 1, index + 1 + count).map((field) => RelativeFilePath.fromGit(field)); + const type = fields[index + 1 + count]; + const message = fields[index + 2 + count]; + messages.push({ paths, type, message: message.trim() }); + index += 3 + count; + } + return { tree: TreeId.fromGit(fields[0].trim()), stages, messages }; }); } @@ -215,7 +222,7 @@ export class GitService { from: TreeId | CommitId, to: TreeId | CommitId, renames: boolean - ): Promise { + ): Promise { const { stdout } = await this.run(root, [ 'diff-tree', '-r', @@ -226,10 +233,10 @@ export class GitService { to.toString() ]); const fields = stdout.split('\0'); - const entries: DiffEntry[] = []; + const entries: Change[] = []; for (let index = 0; index < fields.length && fields[index] !== ''; ) { const [, , , dstBlob, statusWithScore] = fields[index].slice(1).split(' '); - const status = statusWithScore[0] as DiffEntry['status']; + const status = statusWithScore[0] as Change['status']; const first = fields[index + 1]; if (status === 'R' || status === 'C') { entries.push({ diff --git a/src/prompts/sdk/write.ts b/src/prompts/sdk/write.ts index 0e5fa898d..27d7a2389 100644 --- a/src/prompts/sdk/write.ts +++ b/src/prompts/sdk/write.ts @@ -1,9 +1,9 @@ import { log } from '@clack/prompts'; -import { Conflict, ConflictKind, MergeFiles, MergePlan } from '../../application/sdk-merge/merge-plan.js'; import { DirectoryPath } from '../../types/file/directoryPath.js'; import { RelativeFilePath } from '../../types/file/relativeFilePath.js'; import { SdkSaveProblem } from '../../types/sdk-context.js'; import { SdkRepositoryContext } from '../../types/sdk-repository-context.js'; +import { Conflict, ConflictKind, MergeFiles, MergePlan } from '../../types/sdk/merge-plan.js'; import { listedInProse } from '../../utils/string-utils.js'; import { format as f } from '../format.js'; import { noteWrapped } from '../prompt.js'; diff --git a/src/types/sdk-repository-context.ts b/src/types/sdk-repository-context.ts index 65f6a63f8..18569afa0 100644 --- a/src/types/sdk-repository-context.ts +++ b/src/types/sdk-repository-context.ts @@ -1,10 +1,6 @@ import fs from 'node:fs'; import path from 'node:path'; import { err, ok, Result } from 'neverthrow'; -import { hasConflictMarkers, relabelMarkers } from '../application/sdk-merge/markers.js'; -import { MergePlan } from '../application/sdk-merge/merge-plan.js'; -import { parseMergeTreeOutput, StageEntry } from '../application/sdk-merge/merge-tree-output.js'; -import { planMerge } from '../application/sdk-merge/sdk-merge.js'; import { FileService } from '../infrastructure/file-service.js'; import { GitService } from '../infrastructure/git-service.js'; import { errorMessage } from '../utils/error-utils.js'; @@ -13,7 +9,9 @@ import { FileName } from './file/fileName.js'; import { FilePath } from './file/filePath.js'; import { RelativeFilePath } from './file/relativeFilePath.js'; import { CommitId } from './sdk/commit-id.js'; +import { hasConflictMarkers, relabelMarkers } from './sdk/conflict-markers.js'; import { describeGeneration, GenerationRecord, SdkGeneration } from './sdk/generation-record.js'; +import { MergePlan, StageEntry } from './sdk/merge-plan.js'; import { SnapshotRef } from './sdk/snapshot-ref.js'; import { TreeId } from './sdk/tree-id.js'; @@ -204,17 +202,15 @@ export class SdkRepositoryContext { const baseTree = await this.git.treeOfCommit(root, base); const yours = await this.git.commitTree(root, disk, [base], 'apimatic: your changes'); const theirs = await this.git.commitTree(root, generated, [base], 'apimatic: generated'); - const run = await this.git.mergeTree(root, yours, theirs); - const output = parseMergeTreeOutput(run.output); - const merged = TreeId.fromGit(output.tree); - const plan = planMerge({ - merge: output, + const merge = await this.git.mergeTree(root, yours, theirs); + const plan = MergePlan.from({ + merge, yours: await this.git.diffTrees(root, baseTree, disk, true), generated: await this.git.diffTrees(root, baseTree, generated, true), - applied: await this.git.diffTrees(root, disk, merged, false), + applied: await this.git.diffTrees(root, disk, merge.tree, false), basePaths: (await this.git.listTree(root, baseTree)).map((entry) => entry.path) }); - return ok({ plan, merged, stages: output.stages }); + return ok({ plan, merged: merge.tree, stages: merge.stages }); } catch (error) { return err(gitFailed(errorMessage(error))); } diff --git a/src/application/sdk-merge/markers.ts b/src/types/sdk/conflict-markers.ts similarity index 100% rename from src/application/sdk-merge/markers.ts rename to src/types/sdk/conflict-markers.ts diff --git a/src/types/sdk/merge-plan.ts b/src/types/sdk/merge-plan.ts new file mode 100644 index 000000000..36f984e18 --- /dev/null +++ b/src/types/sdk/merge-plan.ts @@ -0,0 +1,259 @@ +import { RelativeFilePath } from '../file/relativeFilePath.js'; +import { TreeId } from './tree-id.js'; + +/** One line of `diff-tree --raw`: how `path` differs between two trees, and the blob it holds in the second. */ +export type Change = { + status: 'A' | 'M' | 'D' | 'T' | 'R' | 'C'; + path: RelativeFilePath; + /** The path in the first tree, for a rename or copy. */ + from?: RelativeFilePath; + /** The blob in the second tree; absent for a deletion. */ + blob?: string; +}; + +/** One conflicted path's version on one side: stage 1 is the base, 2 yours, 3 the generator's. */ +export type StageEntry = { mode: string; oid: string; stage: 1 | 2 | 3; path: RelativeFilePath }; + +/** One of git's informational messages, such as `CONFLICT (modify/delete)` for a path. */ +export type MergeMessage = { paths: RelativeFilePath[]; type: string; message: string }; + +/** What `git merge-tree --write-tree` reports: the merged tree, the stages of every conflicted path, and its messages. */ +export type MergeTreeOutput = { tree: TreeId; stages: StageEntry[]; messages: MergeMessage[] }; + +export type MergeInputs = { + merge: MergeTreeOutput; + /** Base to the disk: what you changed. Renames paired. */ + yours: readonly Change[]; + /** Base to the fresh generation: what the generator changed. Renames paired. */ + generated: readonly Change[]; + /** Disk to the merged tree: what the run writes and removes. No renames. */ + applied: readonly Change[]; + /** Every path the base holds. */ + basePaths: readonly RelativeFilePath[]; +}; + +export type ConflictKind = + /** You and the generator changed the same lines; the file holds markers. */ + | 'bothChanged' + /** You and the generator both added the path with different content; the file holds markers. */ + | 'bothAdded' + /** You deleted the file and the generator changed it; the new version is written back. */ + | 'deletedByYou' + /** You edited the file and the generator no longer produces it; your file stays. */ + | 'noLongerGenerated' + /** You and the generator both changed a binary file; yours stays. */ + | 'binary' + /** A shape git reports that fits none of the above, such as both sides renaming one file differently; `message` is git's. */ + | 'other'; + +/** A file to write, as the blob the merged tree holds for it. */ +export type FileWrite = { path: RelativeFilePath; blob: string }; + +export type Conflict = { path: RelativeFilePath; kind: ConflictKind; message?: string }; + +export type Rename = { from: RelativeFilePath; to: RelativeFilePath; by: 'generator' | 'you' }; + +/** Every path the run touched or left alone, by what happened to it; conflicted paths are listed under `conflicts` instead. */ +export type MergeFiles = { + /** Files the generator produced for the first time. */ + added: readonly RelativeFilePath[]; + /** Unedited files replaced by their new version, or moved to the generator's new name. */ + updated: readonly RelativeFilePath[]; + /** Edited files the generator also changed, merged without conflict. */ + merged: readonly RelativeFilePath[]; + /** Unedited files the generator stopped producing. */ + removed: readonly RelativeFilePath[]; + /** Edited files the generator left alone. */ + kept: readonly RelativeFilePath[]; + /** Files neither side changed. */ + unchanged: readonly RelativeFilePath[]; +}; + +const CONFLICT_PREFIX = 'CONFLICT ('; + +/** The stronger of two accounts of one path, when git reports it more than once. */ +const RANK: Record = { + binary: 5, + bothAdded: 4, + deletedByYou: 3, + noLongerGenerated: 3, + bothChanged: 2, + other: 1 +}; + +function kindOf(type: string, message: string, stages: ReadonlySet): ConflictKind { + if (type === 'CONFLICT (binary)') { + return 'binary'; + } + if (message.startsWith('CONFLICT (add/add)')) { + return 'bothAdded'; + } + if (type === 'CONFLICT (modify/delete)' || type === 'CONFLICT (rename/delete)') { + return stages.has(2) ? 'noLongerGenerated' : 'deletedByYou'; + } + if (type === 'CONFLICT (contents)') { + return 'bothChanged'; + } + return 'other'; +} + +function conflictsOf(merge: MergeTreeOutput): Conflict[] { + const stagesByPath = new Map>(); + for (const entry of merge.stages) { + const key = entry.path.toString(); + stagesByPath.set(key, (stagesByPath.get(key) ?? new Set()).add(entry.stage)); + } + const byPath = new Map(); + for (const { paths, type, message } of merge.messages) { + if (!type.startsWith(CONFLICT_PREFIX)) { + continue; + } + for (const path of paths) { + const key = path.toString(); + const kind = kindOf(type, message, stagesByPath.get(key) ?? new Set()); + const known = byPath.get(key); + if (known === undefined || RANK[kind] > RANK[known.kind]) { + byPath.set(key, kind === 'other' ? { path, kind, message } : { path, kind }); + } + } + } + return [...byPath.values()]; +} + +/** Each change under every path it names, so a rename is found by either name. */ +const changedPaths = (changes: readonly Change[]): Map => + new Map( + changes.flatMap((change): [string, Change][] => + change.from === undefined + ? [[change.path.toString(), change]] + : [ + [change.from.toString(), change], + [change.path.toString(), change] + ] + ) + ); + +/** + * What a run writes and removes in the SDK directory, by path below it with `/` between segments. + * A path the plan does not mention is left as it is. + */ +export class MergePlan { + public readonly writes: readonly FileWrite[]; + public readonly deletes: readonly RelativeFilePath[]; + public readonly conflicts: readonly Conflict[]; + public readonly renames: readonly Rename[]; + public readonly files: MergeFiles; + + constructor( + writes: readonly FileWrite[], + deletes: readonly RelativeFilePath[], + conflicts: readonly Conflict[], + renames: readonly Rename[], + files: MergeFiles + ) { + this.writes = writes; + this.deletes = deletes; + this.conflicts = conflicts; + this.renames = renames; + this.files = files; + } + + /** + * What git's merge means for the SDK directory: the writes and deletions that bring the disk + * to the merged tree, the conflicts by kind, the renames each side made, and every other path + * by what happened to it. + */ + public static from(inputs: MergeInputs): MergePlan { + const conflicts = conflictsOf(inputs.merge); + const conflicted = new Set(conflicts.map((conflict) => conflict.path.toString())); + const yours = changedPaths(inputs.yours); + const generated = changedPaths(inputs.generated); + const base = new Set(inputs.basePaths.map((path) => path.toString())); + + const writes: FileWrite[] = []; + const deletes: RelativeFilePath[] = []; + const files: Record = { + added: [], + updated: [], + merged: [], + removed: [], + kept: [], + unchanged: [] + }; + + for (const change of inputs.applied) { + const key = change.path.toString(); + if (change.status === 'D') { + deletes.push(change.path); + if (!conflicted.has(key)) { + files.removed.push(change.path); + } + continue; + } + writes.push({ path: change.path, blob: change.blob! }); + if (conflicted.has(key)) { + continue; + } + if (!base.has(key) && generated.get(key)?.status === 'A') { + files.added.push(change.path); + } else if (yours.get(key)?.status === 'M' && generated.has(key)) { + files.merged.push(change.path); + } else { + files.updated.push(change.path); + } + } + + for (const [key, change] of yours) { + if (change.status === 'M' && !generated.has(key) && !conflicted.has(key)) { + files.kept.push(change.path); + } + } + for (const path of inputs.basePaths) { + const key = path.toString(); + if (!yours.has(key) && !generated.has(key)) { + files.unchanged.push(path); + } + } + + const renames: Rename[] = [ + ...inputs.generated + .filter((change) => change.status === 'R') + .map((change) => ({ from: change.from!, to: change.path, by: 'generator' as const })), + ...inputs.yours + .filter((change) => change.status === 'R') + .map((change) => ({ from: change.from!, to: change.path, by: 'you' as const })) + ]; + + return new MergePlan(writes, deletes, conflicts, renames, files); + } + + public hasConflicts(): boolean { + return this.conflicts.length > 0; + } + + public conflictedPaths(): RelativeFilePath[] { + return this.conflicts.map((conflict) => conflict.path); + } + + public conflictsByKind(): Record { + const counts: Record = { + bothChanged: 0, + bothAdded: 0, + deletedByYou: 0, + noLongerGenerated: 0, + binary: 0, + other: 0 + }; + for (const conflict of this.conflicts) { + counts[conflict.kind] += 1; + } + return counts; + } + + /** Whether the file at `path` is written with conflict markers in it. */ + public hasMarkers(path: RelativeFilePath): boolean { + return this.conflicts.some( + (conflict) => conflict.path.isEqual(path) && (conflict.kind === 'bothChanged' || conflict.kind === 'bothAdded') + ); + } +} diff --git a/test/actions/sdk/write.test.ts b/test/actions/sdk/write.test.ts index 6925cbf79..7132eb80f 100644 --- a/test/actions/sdk/write.test.ts +++ b/test/actions/sdk/write.test.ts @@ -7,13 +7,13 @@ import { execa } from 'execa'; import { err } from 'neverthrow'; import sinon from 'sinon'; import { SdkWriteAction } from '../../../src/actions/sdk/write.js'; -import { GENERATED_MARKER, YOURS_MARKER } from '../../../src/application/sdk-merge/markers.js'; import { GitService } from '../../../src/infrastructure/git-service.js'; import { ZipService } from '../../../src/infrastructure/zip-service.js'; import { SdkWritePrompts } from '../../../src/prompts/sdk/write.js'; import { DirectoryPath } from '../../../src/types/file/directoryPath.js'; import { SdkContext } from '../../../src/types/sdk-context.js'; import { CommitId } from '../../../src/types/sdk/commit-id.js'; +import { GENERATED_MARKER, YOURS_MARKER } from '../../../src/types/sdk/conflict-markers.js'; import { CodeGenerationVersion, Language, Stability } from '../../../src/types/sdk/generate.js'; import { GenerationRecord, SdkGeneration } from '../../../src/types/sdk/generation-record.js'; import { SdkDestination } from '../../../src/types/sdk/sdk-destination.js'; diff --git a/test/application/sdk-merge/merge-tree-output.test.ts b/test/application/sdk-merge/merge-tree-output.test.ts deleted file mode 100644 index adbd37629..000000000 --- a/test/application/sdk-merge/merge-tree-output.test.ts +++ /dev/null @@ -1,66 +0,0 @@ -import { RelativeFilePath } from '../../../src/types/file/relativeFilePath.js'; -import { TextEncoder } from 'node:util'; -import { expect } from 'chai'; -import { parseMergeTreeOutput } from '../../../src/application/sdk-merge/merge-tree-output.js'; - -const TREE = '26ea736f3f455ff2dc2d7465e0c628536e5d5d52'; -const BASE = 'de980441c3ab03a8c07dda1ad27b8a11f39deb1e'; -const OURS = '7be73ce3c1b1cdaea86e8168dfee8575175953bf'; -const THEIRS = '0c02cccd4ca03aab5fcf3996660d5c0f5020a107'; - -// As git 2.47 printed it for a content conflict in same.txt and a file deleted on one side. -const output = (...fields: string[]) => new TextEncoder().encode(fields.join('\0')); - -const rel = (value: string) => RelativeFilePath.fromGit(value); - -describe('parseMergeTreeOutput', () => { - it('reads the tree, the stages of each conflicted path and the messages', () => { - const parsed = parseMergeTreeOutput( - output( - TREE, - `100644 ${BASE} 1\tsame.txt`, - `100644 ${OURS} 2\tsame.txt`, - `100644 ${THEIRS} 3\tsame.txt`, - `100644 ${BASE} 1\tgone.txt`, - `100644 ${THEIRS} 3\tgone.txt`, - '', - '1', - 'same.txt', - 'Auto-merging', - 'Auto-merging same.txt\n', - '1', - 'same.txt', - 'CONFLICT (contents)', - 'CONFLICT (content): Merge conflict in same.txt\n', - '1', - 'gone.txt', - 'CONFLICT (modify/delete)', - 'CONFLICT (modify/delete): gone.txt deleted in x and modified in y. Version y of gone.txt left in tree.\n', - '' - ) - ); - - expect(parsed.tree).to.equal(TREE); - expect(parsed.stages).to.deep.equal([ - { mode: '100644', oid: BASE, stage: 1, path: rel('same.txt') }, - { mode: '100644', oid: OURS, stage: 2, path: rel('same.txt') }, - { mode: '100644', oid: THEIRS, stage: 3, path: rel('same.txt') }, - { mode: '100644', oid: BASE, stage: 1, path: rel('gone.txt') }, - { mode: '100644', oid: THEIRS, stage: 3, path: rel('gone.txt') } - ]); - expect(parsed.messages.map((message) => [message.paths.map(String), message.type])).to.deep.equal([ - [['same.txt'], 'Auto-merging'], - [['same.txt'], 'CONFLICT (contents)'], - [['gone.txt'], 'CONFLICT (modify/delete)'] - ]); - expect(parsed.messages[2].message).to.equal( - 'CONFLICT (modify/delete): gone.txt deleted in x and modified in y. Version y of gone.txt left in tree.' - ); - }); - - it('reads a clean merge as a tree and nothing else', () => { - const parsed = parseMergeTreeOutput(output(`${TREE}\n`)); - - expect(parsed).to.deep.equal({ tree: TREE, stages: [], messages: [] }); - }); -}); diff --git a/test/infrastructure/git-service.test.ts b/test/infrastructure/git-service.test.ts index b60b7b647..a7e54d929 100644 --- a/test/infrastructure/git-service.test.ts +++ b/test/infrastructure/git-service.test.ts @@ -6,6 +6,7 @@ import { TextDecoder } from 'node:util'; import { expect } from 'chai'; import { GitService } from '../../src/infrastructure/git-service.js'; import { DirectoryPath } from '../../src/types/file/directoryPath.js'; +import { CommitId } from '../../src/types/sdk/commit-id.js'; const text = (content: Uint8Array | undefined) => content === undefined ? undefined : new TextDecoder().decode(content); @@ -161,4 +162,55 @@ describe('GitService', () => { ]); expect(plain.find((change) => change.path.toString() === 'b.txt')?.blob).to.have.lengthOf(40); }); + + describe('mergeTree', () => { + /** A commit in `repo` holding `files`, on `parents`. */ + const commit = async (repo: DirectoryPath, name: string, files: Record, parents: CommitId[]) => { + const work = new DirectoryPath(path.join(root, name)); + for (const [relative, contents] of Object.entries(files)) { + write(work, relative, contents); + } + return git.commitTree(repo, await git.treeOf(repo, work, 'all'), parents, name); + }; + + it('reads the merged tree, the stages of each conflicted path and the messages', async () => { + const repo = await repository(); + const base = await commit(repo, 'base', { 'same.txt': 'one\n', 'gone.txt': 'gone\n' }, []); + const ours = await commit(repo, 'ours', { 'same.txt': 'ours\n', 'gone.txt': 'kept\n' }, [base]); + const theirs = await commit(repo, 'theirs', { 'same.txt': 'theirs\n' }, [base]); + + const merged = await git.mergeTree(repo, ours, theirs); + + expect(merged.stages.map(({ stage, path: staged }) => [staged.toString(), stage])).to.have.deep.members([ + ['same.txt', 1], + ['same.txt', 2], + ['same.txt', 3], + ['gone.txt', 1], + ['gone.txt', 2] + ]); + expect(merged.messages.map(({ paths, type }) => [paths.map(String), type])).to.have.deep.members([ + [['same.txt'], 'Auto-merging'], + [['same.txt'], 'CONFLICT (contents)'], + [['gone.txt'], 'CONFLICT (modify/delete)'] + ]); + expect(merged.messages.find(({ type }) => type === 'CONFLICT (modify/delete)')?.message).to.match( + /^CONFLICT \(modify\/delete\): gone\.txt deleted in .+ left in tree\.$/ + ); + expect((await filesOf(repo, merged.tree)).get('gone.txt')).to.equal('kept\n'); + }); + + it('reads a clean merge as the merged tree and nothing else', async () => { + const repo = await repository(); + const base = await commit(repo, 'base', { 'a.txt': 'a\n', 'b.txt': 'b\n' }, []); + const ours = await commit(repo, 'ours', { 'a.txt': 'A\n', 'b.txt': 'b\n' }, [base]); + const theirs = await commit(repo, 'theirs', { 'a.txt': 'a\n', 'b.txt': 'B\n' }, [base]); + const both = await commit(repo, 'both', { 'a.txt': 'A\n', 'b.txt': 'B\n' }, []); + + const merged = await git.mergeTree(repo, ours, theirs); + + expect(merged.tree.isEqual(await git.treeOfCommit(repo, both))).to.be.true; + expect(merged.stages).to.deep.equal([]); + expect(merged.messages).to.deep.equal([]); + }); + }); }); diff --git a/test/prompts/sdk/write.test.ts b/test/prompts/sdk/write.test.ts index 6128baed9..76b50f075 100644 --- a/test/prompts/sdk/write.test.ts +++ b/test/prompts/sdk/write.test.ts @@ -2,11 +2,11 @@ import { stripVTControlCharacters } from 'node:util'; import { expect } from 'chai'; import sinon from 'sinon'; import { log } from '@clack/prompts'; -import { ConflictKind, MergePlan } from '../../../src/application/sdk-merge/merge-plan.js'; import { SdkWritePrompts } from '../../../src/prompts/sdk/write.js'; import { DirectoryPath } from '../../../src/types/file/directoryPath.js'; import { RelativeFilePath } from '../../../src/types/file/relativeFilePath.js'; import { SdkRepositoryContext } from '../../../src/types/sdk-repository-context.js'; +import { ConflictKind, MergePlan } from '../../../src/types/sdk/merge-plan.js'; const rel = (value: string) => RelativeFilePath.fromGit(value); diff --git a/test/types/sdk-repository-context-merge.test.ts b/test/types/sdk-repository-context-merge.test.ts index 893b4f369..4d195eea6 100644 --- a/test/types/sdk-repository-context-merge.test.ts +++ b/test/types/sdk-repository-context-merge.test.ts @@ -4,12 +4,12 @@ import os from 'os'; import path from 'path'; import { Buffer } from 'node:buffer'; import { expect } from 'chai'; -import { MergePlan } from '../../src/application/sdk-merge/merge-plan.js'; import { GitService } from '../../src/infrastructure/git-service.js'; import { SdkContext } from '../../src/types/sdk-context.js'; import { DirectoryPath } from '../../src/types/file/directoryPath.js'; import { CodeGenerationVersion, Language, Stability } from '../../src/types/sdk/generate.js'; import { SdkGeneration } from '../../src/types/sdk/generation-record.js'; +import { MergePlan } from '../../src/types/sdk/merge-plan.js'; const TYPESCRIPT: SdkGeneration = { language: Language.TYPESCRIPT, diff --git a/test/application/sdk-merge/markers.test.ts b/test/types/sdk/conflict-markers.test.ts similarity index 97% rename from test/application/sdk-merge/markers.test.ts rename to test/types/sdk/conflict-markers.test.ts index 8850c670b..f5763ef1c 100644 --- a/test/application/sdk-merge/markers.test.ts +++ b/test/types/sdk/conflict-markers.test.ts @@ -1,6 +1,6 @@ import { TextDecoder, TextEncoder } from 'node:util'; import { expect } from 'chai'; -import { hasConflictMarkers, relabelMarkers } from '../../../src/application/sdk-merge/markers.js'; +import { hasConflictMarkers, relabelMarkers } from '../../../src/types/sdk/conflict-markers.js'; const bytes = (text: string) => new TextEncoder().encode(text); const text = (content: Uint8Array) => new TextDecoder().decode(content); diff --git a/test/application/sdk-merge/sdk-merge.test.ts b/test/types/sdk/merge-plan.test.ts similarity index 84% rename from test/application/sdk-merge/sdk-merge.test.ts rename to test/types/sdk/merge-plan.test.ts index bd2b59f01..8ff382256 100644 --- a/test/application/sdk-merge/sdk-merge.test.ts +++ b/test/types/sdk/merge-plan.test.ts @@ -1,10 +1,9 @@ import { RelativeFilePath } from '../../../src/types/file/relativeFilePath.js'; -import { MergePlan } from '../../../src/application/sdk-merge/merge-plan.js'; import { expect } from 'chai'; -import { MergeInputs, planMerge } from '../../../src/application/sdk-merge/sdk-merge.js'; -import { MergeTreeOutput } from '../../../src/application/sdk-merge/merge-tree-output.js'; +import { MergeInputs, MergePlan, MergeTreeOutput } from '../../../src/types/sdk/merge-plan.js'; +import { TreeId } from '../../../src/types/sdk/tree-id.js'; -const TREE = 't'.repeat(40); +const TREE = TreeId.fromGit('t'.repeat(40)); const blob = (name: string) => name.padEnd(40, '0'); const merge = (overrides: Partial = {}): MergeTreeOutput => ({ @@ -28,9 +27,9 @@ const counts = (plan: MergePlan) => const rel = (value: string) => RelativeFilePath.fromGit(value); -describe('planMerge', () => { +describe('MergePlan.from', () => { it('turns the diff from the disk to the merged tree into writes and deletions, counted by who changed what', () => { - const plan = planMerge( + const plan = MergePlan.from( inputs({ basePaths: ['a.ts', 'b.ts', 'c.ts', 'd.ts', 'e.ts'].map(rel), yours: [ @@ -63,7 +62,7 @@ describe('planMerge', () => { }); it('names each conflict by its kind, from the message and from which stages are present', () => { - const plan = planMerge( + const plan = MergePlan.from( inputs({ merge: merge({ stages: [ @@ -84,14 +83,26 @@ describe('planMerge', () => { type: 'CONFLICT (contents)', message: 'CONFLICT (add/add): Merge conflict in added.ts' }, - { paths: ['logo.png'].map(rel), type: 'CONFLICT (binary)', message: 'warning: Cannot merge binary files: logo.png' }, + { + paths: ['logo.png'].map(rel), + type: 'CONFLICT (binary)', + message: 'warning: Cannot merge binary files: logo.png' + }, { paths: ['logo.png'].map(rel), type: 'CONFLICT (contents)', message: 'CONFLICT (content): Merge conflict in logo.png' }, - { paths: ['deleted-by-you.ts'].map(rel), type: 'CONFLICT (modify/delete)', message: 'CONFLICT (modify/delete): …' }, - { paths: ['dropped.ts'].map(rel), type: 'CONFLICT (modify/delete)', message: 'CONFLICT (modify/delete): …' }, + { + paths: ['deleted-by-you.ts'].map(rel), + type: 'CONFLICT (modify/delete)', + message: 'CONFLICT (modify/delete): …' + }, + { + paths: ['dropped.ts'].map(rel), + type: 'CONFLICT (modify/delete)', + message: 'CONFLICT (modify/delete): …' + }, { paths: ['x.ts', 'y.ts'].map(rel), type: 'CONFLICT (rename/rename)', @@ -121,7 +132,7 @@ describe('planMerge', () => { }); it('reports the renames each side made', () => { - const plan = planMerge( + const plan = MergePlan.from( inputs({ basePaths: ['a.ts', 'b.ts'].map(rel), yours: [{ status: 'R', from: rel('a.ts'), path: rel('moved/a.ts'), blob: blob('a') }], From 8217ac566e22cb29094fda1c096474e6c3045183 Mon Sep 17 00:00:00 2001 From: Asad Ali <14asadali@gmail.com> Date: Wed, 21 Oct 2026 15:15:38 +0500 Subject: [PATCH 12/29] feat(sdk): share snapshots through the remote After every recorded run the CLI pushes refs/apimatic/pristine/ to the branch's remote (or origin) and says so when it sent something. A run whose record names a commit the repository lacks fetches the ref first, so a clone or a CI checkout merges by itself. Chains that diverged because two machines generated are joined with a two-parent commit; nothing is ever forced. Remote commands run without prompts and with a 30-second timeout, so a missing credential, a refused push or a dead remote is a warning with git's reason and the manual push, never a stuck or failed run. Co-Authored-By: Claude Fable 5.1 --- .ai/plans/custom-code.md | 75 +++++++++----- README.md | 20 ++-- src/actions/sdk/prepare.ts | 21 ++-- src/actions/sdk/write.ts | 26 +++-- src/infrastructure/git-service.ts | 117 ++++++++++++++++++++- src/prompts/sdk/prepare.ts | 40 ++++++-- src/prompts/sdk/write.ts | 16 +++ src/types/sdk-repository-context.ts | 103 +++++++++++++++++-- src/types/sdk/sdk-destination.ts | 7 +- test/actions/sdk/prepare.test.ts | 42 +++++++- test/actions/sdk/write.test.ts | 90 +++++++++++++++-- test/infrastructure/git-service.test.ts | 101 +++++++++++++++++- test/types/sdk-repository-context.test.ts | 118 ++++++++++++++++++++-- 13 files changed, 689 insertions(+), 87 deletions(-) diff --git a/.ai/plans/custom-code.md b/.ai/plans/custom-code.md index e5059dc7a..61375d950 100644 --- a/.ai/plans/custom-code.md +++ b/.ai/plans/custom-code.md @@ -27,8 +27,8 @@ What changes for the developer: new files are added, files the generator stopped producing are removed when unedited. - **Conflicts are explicit**: overlapping changes get git-style markers, the run exits 1 and lists the files. Resolve, rerun. -- **Teams**: the merge base is local to the machine that generated. The CLI never pushes or - fetches; a team that wants a clone to merge pushes `refs/apimatic/pristine/*` itself (3.2). +- **Teams**: after a recorded run the CLI pushes the snapshot ref to the branch's remote (or + `origin`) and says so; a clone fetches it before merging. A push that fails is a warning (3.2). - **No base, no merge**: an SDK directory with no generation record, or whose recorded snapshot is missing, gets the "overwrite?" question as before; the run then records a snapshot so the next one merges. @@ -54,7 +54,7 @@ CLI. | No repository | After the generation succeeds and before the first write, `git init -b main` in the SDK directory, reported afterwards. A non-empty directory without a repository is asked "overwrite?" first. | Without a snapshot of the first generation there is nothing to merge against later. The init is inside the developer's own destination directory. | | Repository found but no record | Ask "overwrite?" as before (skipped by `--force`); then record a snapshot. | An SDK generated by an older CLI, copied in, or built from the portal has no base. | | Record for another generation | A record for another stability (or generator) is "no base": a warning, the overwrite question, a fresh snapshot. | Merging a beta generation over a stable base would be a merge of everything; replacing is what the developer asked for. | -| Remote | Not used. The CLI never pushes or fetches; every snapshot stays local. | Pushing refs behind the developer's back is a surprise. Sharing is the team's choice (3.2). | +| Remote | Used, read-mostly: the pristine ref is pushed after every recorded run and fetched only when a record names a commit the repository lacks (decided 2026-10-21, replacing "not used"). Never forced; a push that fails is a warning. | The manual push and fetch was the one step developers had to remember, and the thing a clone or CI could not do by itself. Pushing is announced in one line, so a write to a remote is never silent. | | Merge engine | `git merge-tree --write-tree` between two temporary commits (yours and the generation) that share the pristine snapshot as parent, run with the CLI's own `-c` settings. The CLI reads the result and writes it into the SDK directory. | No merge code to maintain; git supplies the three-way merge, rename detection and every conflict shape, without touching the working tree, the index or any branch. | | Branches | None. Snapshots are commits under `refs/apimatic/…`, never checked out, never on a branch. | The CLI should not create or switch branches in someone's repository. The base for a run is the one the committed record names, so each branch carries its own. | | Moves and renames | Git's rename detection, both ways, at its usual 50% similarity. | The lab's case 6 lost edits because Speakeasy relies on a header comment; git pairs by content. | @@ -75,30 +75,42 @@ the ref has no suffix. | Item | Holds | Written | Shared | |---|---|---|---| -| `refs/apimatic/pristine/` | A chain of commits, one per generation. Each tree is that generation's output exactly as unzipped: no `.git`, no `.apimatic/`, no developer files. | After every successful write, with plumbing: the generated directory is added to a temporary index (`GIT_INDEX_FILE`, `--work-tree`), then `write-tree`, `commit-tree` and `update-ref`. Author "APIMatic CLI ", message `apimatic: pristine typescript v4 stable`. | Not by the CLI. A team may push it (3.2). | +| `refs/apimatic/pristine/` | A chain of commits, one per generation. Each tree is that generation's output exactly as unzipped: no `.git`, no `.apimatic/`, no developer files. | After every successful write, with plumbing: the generated directory is added to a temporary index (`GIT_INDEX_FILE`, `--work-tree`), then `write-tree`, `commit-tree` and `update-ref`. Author "APIMatic CLI ", message `apimatic: pristine typescript v4 stable`. | Pushed to the remote after every recorded run; fetched by a run whose record names a commit the repository lacks (3.2). | | `refs/apimatic/previous/` | One commit, no parent: the SDK directory as it was on disk the moment before the last run wrote to it (`.git` excluded). Its tree is rooted at the SDK directory, not at the repository root. | Before the merge writes anything. Overwritten each run. | No. Local safety net: `git --work-tree=sdk/typescript restore --source=refs/apimatic/previous/sdk/typescript --worktree -- .` puts the files back, record included (verified in the lab, case 15); files the run added stay and `git status` lists them. | | `/.apimatic/generation.json` | `schemaVersion`, `language`, `codegenVersion`, `stability`, `pristine` (the commit the directory was written from), `generatedAt`, `cliVersion`, and `conflicts` (paths left with markers, empty on a clean run). | After the pristine commit exists, so the two never disagree. | Yes: an ordinary tracked file, committed with the SDK. It is what makes a branch, a worktree or a clone merge against the right base. | ### 3.1 Which snapshot is the base The commit named in the generation record on disk, never the ref's tip. The tip only says -where the chain continues. If the named commit is absent locally, the CLI says which commit -is missing, how a teammate can push it and the reader fetch it (3.2), and offers to overwrite -instead, which records a fresh snapshot. - -### 3.2 Sharing is manual - -The CLI never contacts a remote. A new pristine commit's parent is the local tip, so each -machine keeps its own chain. A team that wants clones and CI to merge rather than overwrite -shares the refs itself: - -```sh -git push origin 'refs/apimatic/pristine/*:refs/apimatic/pristine/*' # after generating -git fetch origin '+refs/apimatic/pristine/*:refs/apimatic/pristine/*' # before regenerating elsewhere -``` - -The record names the exact commit, so a fetched ref that does not contain it is reported -the same way as a missing one. +where the chain continues. If the named commit is absent locally, the CLI fetches the ref from +the remote (3.2) and looks again; if it is still absent, it says which commit is missing, why +(no remote, the remote lacks it, or the fetch failed and how), how to push it by hand, and +offers to overwrite instead, which records a fresh snapshot. + +### 3.2 Sharing through the remote + +The remote is the one the current branch pushes to (`branch..remote`), else `origin`, +else none. After a recorded run the CLI pushes +`refs/apimatic/pristine/` there and prints one line when something was sent; when the +remote already has it, nothing is said. A run fetches only when its record names a commit it +does not hold, and prints one line when the fetch supplied it. + +A chain that moved on at the remote, because another machine generated too, is never forced +over: the CLI fetches the remote tip and, when the two have diverged, joins them with a +two-parent commit (`apimatic: pristine chains joined`), so every machine's snapshots stay +reachable and the push is a fast-forward. + +Every remote command runs without prompts (`GIT_TERMINAL_PROMPT=0`, +`credential.interactive=false`, ssh `BatchMode=yes` unless `GIT_SSH_COMMAND` is set) and with +a 30-second timeout, so a missing credential, a refused push (no rights, a ruleset on +`refs/apimatic/*`, a hook) or a dead remote becomes a warning with git's reason and the +manual `git push :`, never a stuck or failed run. The SDK is written and +recorded either way; only the sharing is missing until someone pushes. + +Fallback by hand, when the CLI could not: `git push refs/apimatic/pristine/:refs/apimatic/pristine/` +on the generating machine, and `git fetch +refs/apimatic/pristine/:refs/apimatic/pristine/` +on the other; the record names the exact commit, so a fetched ref that does not contain it is +reported the same way as a missing one. ### 3.3 Rules @@ -249,9 +261,10 @@ rerun changes nothing, because new equals base. With a changed spec it merges on resolution. Git's index keeps the three stages until `git add` resolves them, as after any merge. -CI: with `--force` nothing is asked; conflicts fail the job with the same list. Opening pull -requests is not the CLI's job; an Action that commits the marked files to a branch for -review is a separate piece of work. +CI: with `--force` nothing is asked; conflicts fail the job with the same list. A CI checkout +fetches the snapshot it needs by itself; its push back usually fails for lack of a write +token, which is a warning. Opening pull requests is not the CLI's job; an Action that commits +the marked files to a branch for review is a separate piece of work. ## 7. Expected behaviour on the lab's cases @@ -272,7 +285,7 @@ The eleven Speakeasy runs from the lab notebook, plus three the lab did not cove | 11 | SDK hooks registration file | written once | unchanged: hooks are generator features, merged like any file | | 12 | Both add the same path with different content | developer's file overwritten | conflict add/add | | 13 | Generator renames an edited file | lost | kept: edits follow the rename | -| 14 | Fresh clone, regenerate | fails unless the hidden refs were pushed | merges if the team pushed and fetched the refs; otherwise a clear message with the commands and the overwrite question | +| 14 | Fresh clone, regenerate | fails unless the hidden refs were pushed | merges by itself: the generating run pushed the ref, the clone fetches it; a clear message with the commands and the overwrite question only when the push had failed | Every row of section 5 and this table is a test over real repositories in `test/types/sdk-repository-context-merge.test.ts`, `test/actions/sdk/prepare.test.ts` (row 14 with @@ -287,6 +300,7 @@ a bare remote) and `test/actions/sdk/write.test.ts`. | 3 | `4d5b9ccf` | `SdkPrepareAction`, `SdkDestination`, `SdkWriteAction`, their prompts, the repository's `inspect`; `GenerateAction` back to validation and generation; `SdkContext.save` keeps `.git`. | | 4, 5 | `87453917` | The missing-snapshot message with the push and fetch commands; `stageConflicts` through `update-index --index-info`; `SdkMergeConflictedEvent`. | | 6 | this file | `CONTEXT.md` terms, the README section and requirement, the command description. | +| 7 | uncommitted | Sharing through the remote: `GitService.remoteOf`, `fetchRef`, `pushRef`, `isAncestor`; `SdkRepositoryContext.remote`, `shareSnapshot`, the fetch inside `inspect`, chains joined on divergence; the prompts; lab cases for a clone, a refused push, a remote attached after generating, and two diverged machines. | PR into `dev`, squash-merged, title `feat(sdk): keep your edits when regenerating an SDK`. @@ -322,8 +336,15 @@ PR into `dev`, squash-merged, title `feat(sdk): keep your edits when regeneratin projects with a planted `.apimatic/generation.json` packed none of it. - **Portal downloads.** `portal generate` ships the server's SDK zips, so a portal's download never contains custom code. Zipping the merged SDK directory from disk is a follow-up. -- **No sharing by the CLI.** Decided: clones and CI overwrite unless the team pushes and - fetches `refs/apimatic/pristine/*` by hand. Revisit if that becomes the common complaint. +- **Sharing by the CLI.** Decided 2026-10-21, reversing the earlier "no remote": the manual + push and fetch was the step developers would forget and the one a clone could not do. The + alternative weighed was a tracked `.apimatic/pristine/` copy (travels with every branch, but + doubles the working tree and every diff); the remote push keeps the tree clean and makes the + failure modes (no rights, no credential, offline) warnings with a manual fallback. +- **No `--offline` flag.** Decided 2026-10-21: the cases it guarded (a read-only remote, a + policy against tool pushes, a dead network) are per-repository, not per-run, and every one + of them is already a warning that does not fail the run. If an opt-out is ever needed, a git + config read by `remoteOf` fits better than a flag. ## 11. Relation to other work diff --git a/README.md b/README.md index c0e48eabe..a4369c2b6 100644 --- a/README.md +++ b/README.md @@ -34,22 +34,22 @@ files, new files are added, and files the generator stopped producing are remove This needs git 2.38 or newer. On the first run, if no git repository encloses the SDK directory, the CLI runs `git init` in it and says so. Each run records the SDK exactly as generated as a commit under a hidden ref, -`refs/apimatic/pristine/`, and names that commit in `/.apimatic/generation.json`, which is committed -with the SDK. Nothing is pushed or fetched by the CLI, no branch is created, and no commit is needed before -regenerating. The SDK directory as it was before the last run is kept under `refs/apimatic/previous/`. +`refs/apimatic/pristine/`, names that commit in `/.apimatic/generation.json`, which is committed +with the SDK, and pushes the ref to the remote your branch pushes to (or `origin`), saying so when it did. A push +that fails, for lack of access, a credential or a network, is a warning, not a failed run; your customizations are +still kept locally. No branch is created, and no commit is needed before regenerating. +The SDK directory as it was before the last run is kept under `refs/apimatic/previous/`. When you and the generator changed the same lines, the file gets git-style conflict markers labelled `Your changes` and `Generated by APIMatic`, the run lists the files and exits with code 1, and `git status` shows them as both modified. Keep what is right, remove the markers, and rerun; until then the command refuses to run. Every other file is already updated. -A clone or a CI job has the record but not the hidden refs, so it asks before replacing the SDK (or replaces it -with `--force`) and records a fresh snapshot. A team that wants clones to merge shares the refs by hand: - -```sh-session -$ git push origin 'refs/apimatic/pristine/*:refs/apimatic/pristine/*' # after generating -$ git fetch origin '+refs/apimatic/pristine/*:refs/apimatic/pristine/*' # before regenerating elsewhere -``` +A clone or a CI job has the record but not the hidden ref, so the CLI fetches the ref from the remote before it +merges. If the remote does not have it either, because the push from the generating machine failed, the CLI says +which commit is missing and how to push it by hand, then asks before replacing the SDK (or replaces it with +`--force`) and records a fresh snapshot. Two machines that regenerate independently are fine: their chains are +joined on the next push, nothing is ever force-pushed. `--zip` is unchanged: no repository, no record, no merge. diff --git a/src/actions/sdk/prepare.ts b/src/actions/sdk/prepare.ts index 99051f9c0..5acc0b07a 100644 --- a/src/actions/sdk/prepare.ts +++ b/src/actions/sdk/prepare.ts @@ -51,9 +51,10 @@ export class SdkPrepareAction { const repository = await sdkContext.repository(); if (repository === undefined) { - return ActionResult.success({ kind: 'record', repository }); + return ActionResult.success({ kind: 'record', repository, remote: undefined }); } - const state = await repository.inspect(generation); + const remote = await repository.remote(); + const state = await repository.inspect(generation, remote); if (state.isErr()) { if (state.error.kind === 'nestedSdkDirectory') { this.prompts.nestedSdkDirectory(state.error.reason); @@ -64,18 +65,24 @@ export class SdkPrepareAction { } switch (state.value.kind) { case 'noRecord': - return ActionResult.success({ kind: 'record', repository }); + return ActionResult.success({ kind: 'record', repository, remote }); case 'otherGeneration': this.prompts.recordNotForThisGeneration(state.value.recorded, generation, sdkContext.directory()); - return ActionResult.success({ kind: 'record', repository }); + return ActionResult.success({ kind: 'record', repository, remote }); case 'snapshotMissing': - this.prompts.snapshotMissing(state.value.commit, repository); - return ActionResult.success({ kind: 'record', repository }); + this.prompts.snapshotMissing(state.value.commit, repository, state.value.fetch); + return ActionResult.success({ kind: 'record', repository, remote }); case 'unresolved': + if (state.value.fetchedFrom !== undefined) { + this.prompts.snapshotsFetched(state.value.fetchedFrom); + } this.prompts.conflictsUnresolved(state.value.paths, repository); return ActionResult.failed(); case 'ready': - return ActionResult.success({ kind: 'merge', repository, base: state.value.base }); + if (state.value.fetchedFrom !== undefined) { + this.prompts.snapshotsFetched(state.value.fetchedFrom); + } + return ActionResult.success({ kind: 'merge', repository, base: state.value.base, remote }); } } } diff --git a/src/actions/sdk/write.ts b/src/actions/sdk/write.ts index 8d6b441ba..2bbcb40c7 100644 --- a/src/actions/sdk/write.ts +++ b/src/actions/sdk/write.ts @@ -28,9 +28,9 @@ export class SdkWriteAction { case 'overwrite': return this.overwrite(sdkContext, tempSdk, destination.zip); case 'record': - return this.overwriteAndRecord(destination.repository, sdkContext, tempSdk, generation); + return this.overwriteAndRecord(destination.repository, destination.remote, sdkContext, tempSdk, generation); case 'merge': - return this.merge(destination.repository, destination.base, tempSdk, generation); + return this.merge(destination.repository, destination.base, destination.remote, tempSdk, generation); } }; @@ -48,6 +48,7 @@ export class SdkWriteAction { // and before the write, which the generator's output cannot collide with. private async overwriteAndRecord( enclosing: SdkRepositoryContext | undefined, + remote: string | undefined, sdkContext: SdkContext, tempSdk: DirectoryPath, generation: SdkGeneration @@ -66,7 +67,7 @@ export class SdkWriteAction { this.prompts.repositoryInitialised(sdk); } const generated = await repository.value.snapshotGenerated(tempSdk); - await this.record(repository.value, generated, generation, []); + await this.record(repository.value, generated, generation, [], remote); this.prompts.sdkGenerated(sdk); return ActionResult.success(); } @@ -74,6 +75,7 @@ export class SdkWriteAction { private async merge( repository: SdkRepositoryContext, base: CommitId, + remote: string | undefined, tempSdk: DirectoryPath, generation: SdkGeneration ): Promise> { @@ -98,7 +100,7 @@ export class SdkWriteAction { if (applied.isErr()) { return this.customCodeFailed(applied.error); } - await this.record(repository, generated, generation, plan.conflictedPaths()); + await this.record(repository, generated, generation, plan.conflictedPaths(), remote); this.prompts.sdkMerged(repository, plan); if (!plan.hasConflicts()) { return ActionResult.success(); @@ -117,12 +119,14 @@ export class SdkWriteAction { } // The SDK is already on disk when this runs, so a failure here is a warning: the next run - // asks before replacing it, as it would any directory without a record. + // asks before replacing it, as it would any directory without a record. Sharing the snapshot + // is a warning too: the developer can push the ref by hand, and teammates ask until then. private async record( repository: SdkRepositoryContext, generated: Result, generation: SdkGeneration, - conflicts: readonly RelativeFilePath[] + conflicts: readonly RelativeFilePath[], + remote: string | undefined ): Promise { const recorded = await generated.match( (tree) => repository.recordGeneration(tree, generation, envInfo.getCLIVersion(), conflicts), @@ -130,6 +134,16 @@ export class SdkWriteAction { ); if (recorded.isErr()) { this.prompts.snapshotNotRecorded(recorded.error.reason); + return; + } + if (remote === undefined) { + return; + } + const shared = await repository.shareSnapshot(remote); + if (shared.isErr()) { + this.prompts.snapshotNotShared(remote, shared.error.reason, repository); + } else if (shared.value !== 'upToDate') { + this.prompts.snapshotShared(remote, shared.value === 'joined'); } } } diff --git a/src/infrastructure/git-service.ts b/src/infrastructure/git-service.ts index 932bc704b..3d153167e 100644 --- a/src/infrastructure/git-service.ts +++ b/src/infrastructure/git-service.ts @@ -18,6 +18,22 @@ export type TreeEntry = { mode: string; type: 'blob' | 'tree' | 'commit'; oid: s export type CommitDescription = { parents: CommitId[]; authorName: string; subject: string }; +/** What a push did: sent the ref, found it already there, or was refused because the remote's ref moved on. */ +export type PushResult = 'pushed' | 'upToDate' | 'rejected'; + +/** A remote that does not answer within this is reported, not waited for. */ +const REMOTE_TIMEOUT_MS = 30_000; + +/** What the server itself said, which git relays as `remote:` lines; failing that, git's first line. */ +function remoteSaid(stderr: string): string { + const lines = stderr + .split('\n') + .map((line) => line.trim()) + .filter((line) => line !== ''); + const remote = lines.filter((line) => line.startsWith('remote:')).map((line) => line.slice('remote:'.length).trim()); + return (remote.length > 0 ? remote : lines.slice(0, 1)).join(' '); +} + /** What `git` reported when a command failed. */ export class GitError extends Error {} @@ -72,7 +88,10 @@ export class GitService { const forced = include === 'all' ? [] : include.filter((relative) => fs.existsSync(relative.in(directory).toString())); if (forced.length > 0) { - await inWorkTree(['add', '--force', '--pathspec-from-file=-', '--pathspec-file-nul'], forced.map(String).join('\0')); + await inWorkTree( + ['add', '--force', '--pathspec-from-file=-', '--pathspec-file-nul'], + forced.map(String).join('\0') + ); } const { stdout } = await inWorkTree(['write-tree']); return TreeId.fromGit(stdout.trim()); @@ -261,7 +280,9 @@ export class GitService { * keep it beside the others. */ public async stageConflicts(root: DirectoryPath, entries: readonly StageEntry[]): Promise { - const removals = [...new Set(entries.map((entry) => entry.path.toString()))].map((path) => `0 ${'0'.repeat(40)} 0\t${path}`); + const removals = [...new Set(entries.map((entry) => entry.path.toString()))].map( + (path) => `0 ${'0'.repeat(40)} 0\t${path}` + ); const stages = entries.map((entry) => `${entry.mode} ${entry.oid} ${entry.stage}\t${entry.path}`); await this.run( root, @@ -272,10 +293,102 @@ export class GitService { ); } + /** The remote the current branch is set to push to, else `origin` when there is one, else none. */ + public async remoteOf(root: DirectoryPath): Promise { + const branch = await this.tryRun(root, ['symbolic-ref', '--short', 'HEAD']); + const configured = + branch === undefined ? undefined : await this.tryRun(root, ['config', `branch.${branch}.remote`]); + if (configured !== undefined && configured !== '.') { + return configured; + } + const remotes = ((await this.tryRun(root, ['remote'])) ?? '').split('\n'); + return remotes.includes('origin') ? 'origin' : undefined; + } + + /** Brings `ref` and its objects over from `remote` and answers with its tip, or none when the remote has no such ref. */ + public async fetchRef(root: DirectoryPath, remote: string, ref: SnapshotRef): Promise { + const result = await this.runRemote(root, ['fetch', '--quiet', '--no-tags', remote, ref.toString()]); + if (result.exitCode !== 0) { + if (/couldn't find remote ref|no such ref/i.test(result.stderr)) { + return undefined; + } + throw new GitError(`git fetch failed: ${result.stderr.trim() || `exit code ${result.exitCode}`}`); + } + const { stdout } = await this.run(root, ['rev-parse', '--verify', '--quiet', 'FETCH_HEAD']); + return CommitId.fromGit(stdout.trim()); + } + + /** Sends `ref` to `remote` as a fast-forward; a ref that moved on at the remote is reported as rejected, never forced. */ + public async pushRef(root: DirectoryPath, remote: string, ref: SnapshotRef): Promise { + const result = await this.runRemote(root, ['push', '--porcelain', remote, `${ref}:${ref}`]); + const status = result.stdout.split('\n').find((line) => line.includes(`${ref}:${ref}`)); + if (status === undefined) { + throw new GitError(`git push failed: ${result.stderr.trim() || `exit code ${result.exitCode}`}`); + } + if (status.startsWith('=')) { + return 'upToDate'; + } + if (status.startsWith('!')) { + if (/fetch first|non-fast-forward/.test(status)) { + return 'rejected'; + } + const summary = status.split(' ').pop()?.trim() ?? ''; + throw new GitError( + `git push failed: ${[summary, remoteSaid(result.stderr)].filter((part) => part !== '').join('; ')}` + ); + } + return 'pushed'; + } + + public async isAncestor(root: DirectoryPath, ancestor: CommitId, descendant: CommitId): Promise { + const result = await this.run( + root, + ['merge-base', '--is-ancestor', ancestor.toString(), descendant.toString()], + {}, + undefined, + undefined, + 'utf8', + [0, 1] + ); + return result.exitCode === 0; + } + public static emptyTree(): TreeId { return TreeId.fromGit(EMPTY_TREE); } + private async tryRun(cwd: DirectoryPath, args: string[]): Promise { + try { + return (await this.run(cwd, args)).stdout.trim(); + } catch { + return undefined; + } + } + + // A remote command may not ask anything: no terminal prompt, no credential helper dialogue, + // no ssh passphrase, and it is given up on after the timeout, so a missing credential or a + // dead remote becomes a reported failure rather than a stuck run. + private async runRemote(cwd: DirectoryPath, args: string[]) { + try { + return await execa('git', ['-c', 'credential.interactive=false', ...args], { + cwd: cwd.toString(), + env: { + GIT_TERMINAL_PROMPT: '0', + LC_ALL: 'C', + GIT_SSH_COMMAND: process.env.GIT_SSH_COMMAND ?? 'ssh -o BatchMode=yes' + }, + timeout: REMOTE_TIMEOUT_MS, + reject: false + }); + } catch (error) { + throw new GitError( + error instanceof ExecaError && error.timedOut + ? `the remote did not answer within ${REMOTE_TIMEOUT_MS / 1000} seconds` + : errorMessage(error) + ); + } + } + private async gitDir(root: DirectoryPath): Promise { const { stdout } = await this.run(root, ['rev-parse', '--absolute-git-dir']); return stdout.trim(); diff --git a/src/prompts/sdk/prepare.ts b/src/prompts/sdk/prepare.ts index d6539194d..098f33a05 100644 --- a/src/prompts/sdk/prepare.ts +++ b/src/prompts/sdk/prepare.ts @@ -4,8 +4,7 @@ import { RelativeFilePath } from '../../types/file/relativeFilePath.js'; import { CommitId } from '../../types/sdk/commit-id.js'; import { describeGeneration, SdkGeneration } from '../../types/sdk/generation-record.js'; import { GitVersion } from '../../types/sdk/git-version.js'; -import { SdkRepositoryContext } from '../../types/sdk-repository-context.js'; -import { SnapshotRef } from '../../types/sdk/snapshot-ref.js'; +import { SdkRepositoryContext, SnapshotFetch } from '../../types/sdk-repository-context.js'; import { format as f } from '../format.js'; const plural = (count: number, noun: string) => `${count} ${noun}${count === 1 ? '' : 's'}`; @@ -52,17 +51,20 @@ export class SdkPreparePrompts { log.warn(message); } - public snapshotMissing(commit: CommitId, repository: SdkRepositoryContext) { - const refs = `'${SnapshotRef.pristineNamespace()}/*:${SnapshotRef.pristineNamespace()}/*'`; + public snapshotMissing(commit: CommitId, repository: SdkRepositoryContext, fetch: SnapshotFetch) { + const ref = repository.pristineRef().toString(); const message = `The snapshot this SDK was generated from, commit ${f.var(commit.toString())}, is not in the repository at ` + - `${f.relativePath(repository.root())}, so your customizations cannot be merged.\n` + - `Whoever generated it can share it with ${f.cmd('git', 'push', 'origin', refs)}\n` + - `and you can fetch it with ${f.cmd('git', 'fetch', 'origin', `'+${refs.slice(1)}`)}, then rerun.\n` + + `${f.relativePath(repository.root())}, so your customizations cannot be merged. ${whereItWent(fetch)}\n` + + `${howToShare(fetch, ref)}\n` + `Otherwise the SDK will be replaced and a fresh snapshot recorded.`; log.warn(message); } + public snapshotsFetched(remote: string) { + log.info(`Fetched the SDK's snapshots from ${f.var(remote)}.`); + } + public conflictsUnresolved(paths: readonly RelativeFilePath[], repository: SdkRepositoryContext) { const one = paths.length === 1; const message = @@ -72,3 +74,27 @@ export class SdkPreparePrompts { log.error(message); } } + +function whereItWent(fetch: SnapshotFetch): string { + switch (fetch.kind) { + case 'noRemote': + return 'The repository has no remote to fetch it from.'; + case 'absent': + return `${f.var(fetch.remote)} does not have it either.`; + case 'failed': + return `It could not be fetched from ${f.var(fetch.remote)}: ${fetch.reason}`; + } +} + +function howToShare(fetch: SnapshotFetch, ref: string): string { + const remote = fetch.kind === 'noRemote' ? '' : fetch.remote; + return ( + `Whoever generated it shares it by regenerating with a remote, or with ${f.cmd( + 'git', + 'push', + remote, + `${ref}:${ref}` + )}; ` + + `then rerun here${fetch.kind === 'noRemote' ? ` after ${f.cmd('git', 'fetch', remote, `+${ref}:${ref}`)}` : ''}.` + ); +} diff --git a/src/prompts/sdk/write.ts b/src/prompts/sdk/write.ts index 27d7a2389..9893c62b0 100644 --- a/src/prompts/sdk/write.ts +++ b/src/prompts/sdk/write.ts @@ -65,6 +65,22 @@ export class SdkWritePrompts { log.warn(message); } + public snapshotShared(remote: string, joined: boolean) { + const message = joined + ? `Pushed the SDK's snapshot to ${f.var(remote)}, joined with the one another machine pushed meanwhile.` + : `Pushed the SDK's snapshot to ${f.var(remote)}, so a clone can merge too.`; + log.info(message); + } + + public snapshotNotShared(remote: string, reason: string, repository: SdkRepositoryContext) { + const ref = repository.pristineRef().toString(); + const message = + `The SDK's snapshot could not be pushed to ${f.var(remote)}: ${reason}\n` + + `A clone will replace the SDK instead of merging until it is shared; ` + + `${f.cmd('git', 'push', remote, `${ref}:${ref}`)} does it by hand.`; + log.warn(message); + } + public sdkMerged(repository: SdkRepositoryContext, plan: MergePlan) { const generated = GENERATOR_FILES.filter((kind) => plan.files[kind].length > 0).map( (kind) => `${plural(plan.files[kind].length, 'file')} ${kind}` diff --git a/src/types/sdk-repository-context.ts b/src/types/sdk-repository-context.ts index 18569afa0..e825a5141 100644 --- a/src/types/sdk-repository-context.ts +++ b/src/types/sdk-repository-context.ts @@ -20,13 +20,22 @@ export type SdkRepositoryProblem = { reason: string; }; +/** Why a recorded snapshot is still absent after the one place it could have come from was tried. */ +export type SnapshotFetch = + | { kind: 'noRemote' } + | { kind: 'absent'; remote: string } + | { kind: 'failed'; remote: string; reason: string }; + /** What the repository knows about its SDK directory before a run, from the record and the refs. */ export type SdkRepositoryState = | { kind: 'noRecord' } | { kind: 'otherGeneration'; recorded: SdkGeneration } - | { kind: 'snapshotMissing'; commit: CommitId } - | { kind: 'unresolved'; paths: RelativeFilePath[] } - | { kind: 'ready'; base: CommitId }; + | { kind: 'snapshotMissing'; commit: CommitId; fetch: SnapshotFetch } + | { kind: 'unresolved'; paths: RelativeFilePath[]; fetchedFrom?: string } + | { kind: 'ready'; base: CommitId; fetchedFrom?: string }; + +/** What sharing a snapshot did at the remote. */ +export type SnapshotShare = 'pushed' | 'upToDate' | 'joined'; /** The plan, the merged tree, and git's versions of every conflicted path, below the SDK directory. */ export type MergeOutcome = { plan: MergePlan; merged: TreeId; stages: StageEntry[] }; @@ -77,8 +86,19 @@ export class SdkRepositoryContext { return relative.below(this.sdkPath()); } - /** Everything a run needs to know before it decides how to write; a nested SDK directory is the one refusal. */ - public async inspect(generation: SdkGeneration): Promise> { + /** The remote the SDK's snapshots are shared through: where the current branch pushes, else `origin`. */ + public async remote(): Promise { + return this.git.remoteOf(this.rootDirectory); + } + + /** + * Everything a run needs to know before it decides how to write; a nested SDK directory is + * the one refusal. A recorded snapshot the repository lacks is fetched from `remote` first. + */ + public async inspect( + generation: SdkGeneration, + remote: string | undefined + ): Promise> { const notNested = await this.verifyNotNested(); if (notNested.isErr()) { return err(notNested.error); @@ -91,11 +111,51 @@ export class SdkRepositoryContext { return ok({ kind: 'otherGeneration', recorded: record.generatedAs() }); } const base = record.pristineCommit(); + let fetchedFrom: string | undefined; if (!(await this.git.hasCommit(this.rootDirectory, base))) { - return ok({ kind: 'snapshotMissing', commit: base }); + if (remote === undefined) { + return ok({ kind: 'snapshotMissing', commit: base, fetch: { kind: 'noRemote' } }); + } + const fetched = await this.fetchSnapshots(remote); + if (fetched.isErr()) { + return ok({ + kind: 'snapshotMissing', + commit: base, + fetch: { kind: 'failed', remote, reason: fetched.error.reason } + }); + } + if (!(await this.git.hasCommit(this.rootDirectory, base))) { + return ok({ kind: 'snapshotMissing', commit: base, fetch: { kind: 'absent', remote } }); + } + fetchedFrom = remote; } const paths = await this.unresolvedConflicts(record); - return ok(paths.length > 0 ? { kind: 'unresolved', paths } : { kind: 'ready', base }); + return ok(paths.length > 0 ? { kind: 'unresolved', paths, fetchedFrom } : { kind: 'ready', base, fetchedFrom }); + } + + /** + * Sends the pristine chain to `remote`. A chain that moved on there (another machine + * generated too) is joined into this one, so every snapshot stays reachable and the push is + * a fast-forward; nothing is ever forced. + */ + public async shareSnapshot(remote: string): Promise> { + try { + const first = await this.git.pushRef(this.rootDirectory, remote, this.pristineRef()); + if (first !== 'rejected') { + return ok(first); + } + const absorbed = await this.fetchSnapshots(remote); + if (absorbed.isErr()) { + return err(absorbed.error); + } + const second = await this.git.pushRef(this.rootDirectory, remote, this.pristineRef()); + if (second === 'rejected') { + return err(gitFailed(`${remote} refused the snapshot twice; try again`)); + } + return ok(second === 'pushed' ? 'joined' : second); + } catch (error) { + return err(gitFailed(errorMessage(error))); + } } /** A freshly generated SDK as a tree: every file, whatever its own `.gitignore` says. */ @@ -248,6 +308,35 @@ export class SdkRepositoryContext { } } + // Brings the remote's chain in and makes the local ref reach it: moved onto it when the local + // one is behind, left alone when ahead, and joined with a two-parent commit when the two have + // diverged, so a later push is a fast-forward that keeps every machine's snapshots. + private async fetchSnapshots(remote: string): Promise> { + try { + const ref = this.pristineRef(); + const remoteTip = await this.git.fetchRef(this.rootDirectory, remote, ref); + if (remoteTip === undefined) { + return ok(undefined); + } + const localTip = await this.git.resolveRef(this.rootDirectory, ref); + if (localTip === undefined || (await this.git.isAncestor(this.rootDirectory, localTip, remoteTip))) { + await this.git.updateRef(this.rootDirectory, ref, remoteTip); + } else if (!(await this.git.isAncestor(this.rootDirectory, remoteTip, localTip))) { + const tree = await this.git.treeOfCommit(this.rootDirectory, localTip); + const joined = await this.git.commitTree( + this.rootDirectory, + tree, + [localTip, remoteTip], + 'apimatic: pristine chains joined' + ); + await this.git.updateRef(this.rootDirectory, ref, joined); + } + return ok(undefined); + } catch (error) { + return err(gitFailed(errorMessage(error))); + } + } + // The ref name is the SDK directory's path, so one SDK directory inside another cannot have its own. private async verifyNotNested(): Promise> { const ref = this.pristineRef(); diff --git a/src/types/sdk/sdk-destination.ts b/src/types/sdk/sdk-destination.ts index 9af75eb80..ecb13b6d4 100644 --- a/src/types/sdk/sdk-destination.ts +++ b/src/types/sdk/sdk-destination.ts @@ -4,9 +4,10 @@ import { SdkRepositoryContext } from '../sdk-repository-context.js'; /** * How a run will write the SDK directory, decided before anything is uploaded: replaced as * today, when nothing can be kept; replaced and then recorded, so the next run has a base; or - * merged over the developer's changes against the base the generation record names. + * merged over the developer's changes against the base the generation record names. `remote` + * is where the snapshot is shared afterwards, none when the repository has no remote. */ export type SdkDestination = | { kind: 'overwrite'; zip: boolean } - | { kind: 'record'; repository: SdkRepositoryContext | undefined } - | { kind: 'merge'; repository: SdkRepositoryContext; base: CommitId }; + | { kind: 'record'; repository: SdkRepositoryContext | undefined; remote: string | undefined } + | { kind: 'merge'; repository: SdkRepositoryContext; base: CommitId; remote: string | undefined }; diff --git a/test/actions/sdk/prepare.test.ts b/test/actions/sdk/prepare.test.ts index 58e5e850a..b1d5fd3df 100644 --- a/test/actions/sdk/prepare.test.ts +++ b/test/actions/sdk/prepare.test.ts @@ -89,7 +89,7 @@ describe('SdkPrepareAction', () => { } it('records into a repository still to be made when nothing encloses the SDK directory', async () => { - expect((await execute()).getValue()).to.deep.equal({ kind: 'record', repository: undefined }); + expect((await execute()).getValue()).to.deep.equal({ kind: 'record', repository: undefined, remote: undefined }); }); it('records into the enclosing repository when the directory has no record', async () => { @@ -135,7 +135,7 @@ describe('SdkPrepareAction', () => { await sh(root, 'init', '-q', '--bare', remote); await sh(root, 'remote', 'add', 'origin', remote); await sh(root, 'push', '-q', 'origin', 'main'); - await sh(root, 'clone', '-q', remote, clone); + await sh(root, 'clone', '-q', '--no-local', remote, clone); sdkContext = new SdkContext(Language.PYTHON, new DirectoryPath(path.join(clone, 'sdk'))); const snapshotMissing = sinon.stub(SdkPreparePrompts.prototype, 'snapshotMissing'); @@ -219,4 +219,42 @@ describe('SdkPrepareAction', () => { expect(overwriteSdk.called).to.be.false; }); }); + + describe('sharing through the remote', () => { + const sh = (cwd: string, ...args: string[]) => + execa('git', ['-c', 'user.name=t', '-c', 'user.email=t@t', ...args], { cwd }); + + /** The developer's project pushed to a bare origin, its snapshot shared, and a clone of it. */ + const shared = async (): Promise<{ clone: string; origin: string; base: CommitId }> => { + const origin = fs.mkdtempSync(path.join(os.tmpdir(), 'prepare-sdk-origin-')); + const clone = fs.mkdtempSync(path.join(os.tmpdir(), 'prepare-sdk-clone-')); + const repository = await recorded({ 'README.md': '# sdk' }); + const base = (await repository.readRecord())!.pristineCommit(); + await sh(root, 'add', '.'); + await sh(root, 'commit', '-qm', 'sdk'); + await sh(root, 'init', '-q', '--bare', origin); + await sh(root, 'remote', 'add', 'origin', origin); + await sh(root, 'push', '-q', 'origin', 'main'); + (await repository.shareSnapshot('origin'))._unsafeUnwrap(); + await sh(root, 'clone', '-q', '--no-local', origin, clone); + sdkContext = new SdkContext(Language.PYTHON, new DirectoryPath(path.join(clone, 'sdk'))); + return { clone, origin, base }; + }; + + it('merges in a clone by itself, fetching the snapshot the origin shared', async () => { + const { clone, origin, base } = await shared(); + try { + const snapshotsFetched = sinon.stub(SdkPreparePrompts.prototype, 'snapshotsFetched'); + + const destination = (await execute()).getValue(); + + expect(destination.kind === 'merge' && destination.base.isEqual(base)).to.be.true; + expect(destination.kind === 'merge' && destination.remote).to.equal('origin'); + expect(snapshotsFetched.firstCall.args[0]).to.equal('origin'); + } finally { + fs.rmSync(clone, { recursive: true, force: true }); + fs.rmSync(origin, { recursive: true, force: true }); + } + }); + }); }); diff --git a/test/actions/sdk/write.test.ts b/test/actions/sdk/write.test.ts index 7132eb80f..7d4ce45a8 100644 --- a/test/actions/sdk/write.test.ts +++ b/test/actions/sdk/write.test.ts @@ -4,7 +4,7 @@ import os from 'os'; import path from 'path'; import { expect } from 'chai'; import { execa } from 'execa'; -import { err } from 'neverthrow'; +import { err, ok } from 'neverthrow'; import sinon from 'sinon'; import { SdkWriteAction } from '../../../src/actions/sdk/write.js'; import { GitService } from '../../../src/infrastructure/git-service.js'; @@ -124,7 +124,10 @@ describe('SdkWriteAction', () => { it('initialises a repository in the SDK directory, says so, and records the snapshot', async () => { const repositoryInitialised = sinon.stub(SdkWritePrompts.prototype, 'repositoryInitialised'); - const result = await execute({ kind: 'record', repository: undefined }, { 'README.md': '# sdk', 'a.py': 'x' }); + const result = await execute( + { kind: 'record', repository: undefined, remote: undefined }, + { 'README.md': '# sdk', 'a.py': 'x' } + ); expect(result.isSuccess()).to.be.true; expect(repositoryInitialised.firstCall.args[0].isEqual(sdk())).to.be.true; @@ -143,7 +146,8 @@ describe('SdkWriteAction', () => { const repository = (await sdkContext.repository())!; const repositoryInitialised = sinon.stub(SdkWritePrompts.prototype, 'repositoryInitialised'); - expect((await execute({ kind: 'record', repository }, { 'README.md': '# sdk' })).isSuccess()).to.be.true; + expect((await execute({ kind: 'record', repository, remote: undefined }, { 'README.md': '# sdk' })).isSuccess()) + .to.be.true; expect(repositoryInitialised.called).to.be.false; expect(fs.existsSync(at('.git'))).to.be.false; @@ -158,8 +162,11 @@ describe('SdkWriteAction', () => { .resolves(err({ kind: 'gitFailed', reason: 'git broke' })); const snapshotNotRecorded = sinon.stub(SdkWritePrompts.prototype, 'snapshotNotRecorded'); - expect((await execute({ kind: 'record', repository: undefined }, { 'README.md': '# sdk' })).isSuccess()).to.be - .true; + expect( + ( + await execute({ kind: 'record', repository: undefined, remote: undefined }, { 'README.md': '# sdk' }) + ).isSuccess() + ).to.be.true; expect(read('README.md')).to.equal('# sdk'); expect(snapshotNotRecorded.firstCall.args[0]).to.equal('git broke'); @@ -173,7 +180,7 @@ describe('SdkWriteAction', () => { const sdkMerged = sinon.stub(SdkWritePrompts.prototype, 'sdkMerged'); const result = await execute( - { kind: 'merge', repository, base }, + { kind: 'merge', repository, base, remote: undefined }, { 'README.md': '# sdk v2', 'models/book.py': MODEL_V2, 'models/author.py': 'class Author: ...' } ); @@ -206,7 +213,7 @@ describe('SdkWriteAction', () => { const conflicts = sinon.stub(SdkWritePrompts.prototype, 'conflicts'); const result = await execute( - { kind: 'merge', repository, base }, + { kind: 'merge', repository, base, remote: undefined }, { 'README.md': '# sdk v2', 'models/book.py': MODEL_V2 } ); @@ -237,7 +244,10 @@ describe('SdkWriteAction', () => { const conflictsNotStaged = sinon.stub(SdkWritePrompts.prototype, 'conflictsNotStaged'); const conflicts = sinon.stub(SdkWritePrompts.prototype, 'conflicts'); - const result = await execute({ kind: 'merge', repository, base }, { 'models/book.py': MODEL_V2 }); + const result = await execute( + { kind: 'merge', repository, base, remote: undefined }, + { 'models/book.py': MODEL_V2 } + ); expect(result.isFailed()).to.be.true; expect(result.getMessage()).to.equal('Resolve the conflicts and rerun'); @@ -254,11 +264,73 @@ describe('SdkWriteAction', () => { .resolves(err({ kind: 'gitFailed', reason: 'git broke' })); const customCodeFailed = sinon.stub(SdkWritePrompts.prototype, 'customCodeFailed'); - expect((await execute({ kind: 'merge', repository, base }, { 'README.md': '# sdk v2' })).isFailed()).to.be.true; + expect( + (await execute({ kind: 'merge', repository, base, remote: undefined }, { 'README.md': '# sdk v2' })).isFailed() + ).to.be.true; expect(read('README.md')).to.equal('# edited'); expect(record().pristineCommit().isEqual(base)).to.be.true; expect(customCodeFailed.firstCall.args[0]).to.equal('git broke'); }); }); + + describe('sharing the snapshot', () => { + beforeEach(() => { + sinon.stub(SdkWritePrompts.prototype, 'repositoryInitialised'); + }); + + it('pushes it to the remote the destination names, and says so', async () => { + const share = sinon.stub(SdkRepositoryContext.prototype, 'shareSnapshot').resolves(ok('pushed')); + const snapshotShared = sinon.stub(SdkWritePrompts.prototype, 'snapshotShared'); + + const result = await execute( + { kind: 'record', repository: undefined, remote: 'origin' }, + { 'README.md': '# sdk' } + ); + + expect(result.isSuccess()).to.be.true; + expect(share.firstCall.args[0]).to.equal('origin'); + expect(snapshotShared.firstCall.args).to.deep.equal(['origin', false]); + }); + + it('says nothing when the remote already had it', async () => { + sinon.stub(SdkRepositoryContext.prototype, 'shareSnapshot').resolves(ok('upToDate')); + const snapshotShared = sinon.stub(SdkWritePrompts.prototype, 'snapshotShared'); + + expect( + ( + await execute({ kind: 'record', repository: undefined, remote: 'origin' }, { 'README.md': '# sdk' }) + ).isSuccess() + ).to.be.true; + + expect(snapshotShared.called).to.be.false; + }); + + it('warns, and still succeeds, when the push fails', async () => { + sinon + .stub(SdkRepositoryContext.prototype, 'shareSnapshot') + .resolves(err({ kind: 'gitFailed', reason: 'no access' })); + const snapshotNotShared = sinon.stub(SdkWritePrompts.prototype, 'snapshotNotShared'); + + const result = await execute( + { kind: 'record', repository: undefined, remote: 'origin' }, + { 'README.md': '# sdk' } + ); + + expect(result.isSuccess()).to.be.true; + expect(snapshotNotShared.firstCall.args.slice(0, 2)).to.deep.equal(['origin', 'no access']); + }); + + it('leaves the remote alone when the destination names none', async () => { + const share = sinon.stub(SdkRepositoryContext.prototype, 'shareSnapshot'); + + expect( + ( + await execute({ kind: 'record', repository: undefined, remote: undefined }, { 'README.md': '# sdk' }) + ).isSuccess() + ).to.be.true; + + expect(share.called).to.be.false; + }); + }); }); diff --git a/test/infrastructure/git-service.test.ts b/test/infrastructure/git-service.test.ts index a7e54d929..0d1014e1f 100644 --- a/test/infrastructure/git-service.test.ts +++ b/test/infrastructure/git-service.test.ts @@ -4,9 +4,11 @@ import os from 'os'; import path from 'path'; import { TextDecoder } from 'node:util'; import { expect } from 'chai'; +import { execa } from 'execa'; import { GitService } from '../../src/infrastructure/git-service.js'; import { DirectoryPath } from '../../src/types/file/directoryPath.js'; import { CommitId } from '../../src/types/sdk/commit-id.js'; +import { SnapshotRef } from '../../src/types/sdk/snapshot-ref.js'; const text = (content: Uint8Array | undefined) => content === undefined ? undefined : new TextDecoder().decode(content); @@ -144,7 +146,9 @@ describe('GitService', () => { const paired = await git.diffTrees(repo, from, to, true); const plain = await git.diffTrees(repo, from, to, false); - expect(paired.map(({ status, path: changed, from: origin }) => [status, changed.toString(), origin?.toString()])).to.deep.equal( + expect( + paired.map(({ status, path: changed, from: origin }) => [status, changed.toString(), origin?.toString()]) + ).to.deep.equal( [ ['M', 'b.txt', undefined], ['C', 'c.txt', undefined], @@ -213,4 +217,99 @@ describe('GitService', () => { expect(merged.messages).to.deep.equal([]); }); }); + + describe('remotes', () => { + const ref = SnapshotRef.pristine('sdk/typescript'); + + const bare = async (name = 'origin.git'): Promise => { + const directory = new DirectoryPath(path.join(root, name)); + await execa('git', ['init', '-q', '--bare', directory.toString()]); + return directory; + }; + + const withRemote = async (name: string, origin: DirectoryPath): Promise => { + const repo = await repository(name); + await execa('git', ['remote', 'add', 'origin', origin.toString()], { cwd: repo.toString() }); + return repo; + }; + + /** One more snapshot on `ref` in `repo`, holding `content`. */ + const snapshot = async (repo: DirectoryPath, content: string) => { + const work = new DirectoryPath(path.join(root, `work-${content}`)); + write(work, 'a.txt', content); + const tree = await git.treeOf(repo, work, 'all'); + const parent = await git.resolveRef(repo, ref); + const commit = await git.commitTree(repo, tree, parent === undefined ? [] : [parent], 'snapshot'); + await git.updateRef(repo, ref, commit); + return commit; + }; + + it('names the remote the current branch pushes to, else origin, else none', async () => { + const repo = await repository(); + expect(await git.remoteOf(repo)).to.be.undefined; + + await execa('git', ['remote', 'add', 'origin', (await bare()).toString()], { cwd: repo.toString() }); + expect(await git.remoteOf(repo)).to.equal('origin'); + + await execa('git', ['remote', 'add', 'fork', (await bare('fork.git')).toString()], { cwd: repo.toString() }); + await execa('git', ['config', 'branch.main.remote', 'fork'], { cwd: repo.toString() }); + expect(await git.remoteOf(repo)).to.equal('fork'); + }); + + it('pushes a ref as a fast-forward, reports one already there, and never forces one that moved on', async () => { + const origin = await bare(); + const a = await withRemote('a', origin); + const b = await withRemote('b', origin); + const first = await snapshot(a, 'one'); + + expect(await git.pushRef(a, 'origin', ref)).to.equal('pushed'); + expect(await git.pushRef(a, 'origin', ref)).to.equal('upToDate'); + await snapshot(b, 'two'); + expect(await git.pushRef(b, 'origin', ref)).to.equal('rejected'); + expect((await git.resolveRef(origin, ref))?.isEqual(first)).to.be.true; + }); + + it('fetches a ref with its objects, and answers with nothing for a ref the remote lacks', async () => { + const origin = await bare(); + const a = await withRemote('a', origin); + const b = await withRemote('b', origin); + const first = await snapshot(a, 'one'); + await git.pushRef(a, 'origin', ref); + + expect(await git.fetchRef(b, 'origin', SnapshotRef.pristine('elsewhere'))).to.be.undefined; + const tip = await git.fetchRef(b, 'origin', ref); + + expect(tip?.isEqual(first)).to.be.true; + expect(await git.hasCommit(b, first)).to.be.true; + }); + + it('reports a push the remote refuses, in the remote’s own words', async () => { + const origin = await bare(); + fs.writeFileSync( + path.join(origin.toString(), 'hooks', 'pre-receive'), + '#!/bin/sh\necho "snapshots are not accepted here"\nexit 1\n', + { mode: 0o755 } + ); + const a = await withRemote('a', origin); + await snapshot(a, 'one'); + + let message = ''; + try { + await git.pushRef(a, 'origin', ref); + } catch (error) { + message = (error as Error).message; + } + + expect(message).to.contain('snapshots are not accepted here'); + }); + + it('tells an ancestor from a stranger', async () => { + const repo = await repository(); + const first = await snapshot(repo, 'one'); + const second = await snapshot(repo, 'two'); + + expect(await git.isAncestor(repo, first, second)).to.be.true; + expect(await git.isAncestor(repo, second, first)).to.be.false; + }); + }); }); diff --git a/test/types/sdk-repository-context.test.ts b/test/types/sdk-repository-context.test.ts index ebc414d7a..4940f024a 100644 --- a/test/types/sdk-repository-context.test.ts +++ b/test/types/sdk-repository-context.test.ts @@ -2,6 +2,7 @@ import fs from 'fs'; import os from 'os'; import path from 'path'; import { expect } from 'chai'; +import { execa } from 'execa'; import { GitService } from '../../src/infrastructure/git-service.js'; import { DirectoryPath } from '../../src/types/file/directoryPath.js'; import { RelativeFilePath } from '../../src/types/file/relativeFilePath.js'; @@ -264,7 +265,7 @@ describe('SdkRepositoryContext', () => { it('finds no record', async () => { const repository = await project(); - expect((await repository.inspect(TYPESCRIPT))._unsafeUnwrap()).to.deep.equal({ kind: 'noRecord' }); + expect((await repository.inspect(TYPESCRIPT, undefined))._unsafeUnwrap()).to.deep.equal({ kind: 'noRecord' }); }); it('finds a record for another generation', async () => { @@ -275,7 +276,7 @@ describe('SdkRepositoryContext', () => { GenerationRecord.written({ ...TYPESCRIPT, stability: Stability.BETA }, pristine, new Date(), '2.3.0') ); - const state = (await repository.inspect(TYPESCRIPT))._unsafeUnwrap(); + const state = (await repository.inspect(TYPESCRIPT, undefined))._unsafeUnwrap(); expect(state.kind).to.equal('otherGeneration'); expect(state.kind === 'otherGeneration' && state.recorded.stability).to.equal(Stability.BETA); @@ -286,7 +287,7 @@ describe('SdkRepositoryContext', () => { const missing = CommitId.create('f'.repeat(40))!; await writeRecord(repository, GenerationRecord.written(TYPESCRIPT, missing, new Date(), '2.3.0')); - const state = (await repository.inspect(TYPESCRIPT))._unsafeUnwrap(); + const state = (await repository.inspect(TYPESCRIPT, undefined))._unsafeUnwrap(); expect(state.kind).to.equal('snapshotMissing'); expect(state.kind === 'snapshotMissing' && state.commit.isEqual(missing)).to.be.true; @@ -309,7 +310,7 @@ describe('SdkRepositoryContext', () => { write(sdk, 'src/a.ts', MARKED); write(sdk, 'src/b.ts', 'resolved\n'); - const state = (await repository.inspect(TYPESCRIPT))._unsafeUnwrap(); + const state = (await repository.inspect(TYPESCRIPT, undefined))._unsafeUnwrap(); expect(state).to.include({ kind: 'unresolved' }); expect(state.kind === 'unresolved' && state.paths.map(String)).to.deep.equal(['src/a.ts']); @@ -324,7 +325,7 @@ describe('SdkRepositoryContext', () => { ); write(repository.sdkDirectory(), 'src/a.ts', 'resolved\n'); - const state = (await repository.inspect(TYPESCRIPT))._unsafeUnwrap(); + const state = (await repository.inspect(TYPESCRIPT, undefined))._unsafeUnwrap(); expect(state.kind).to.equal('ready'); expect(state.kind === 'ready' && state.base.isEqual(pristine)).to.be.true; @@ -335,7 +336,112 @@ describe('SdkRepositoryContext', () => { (await record(outer, { 'README.md': '# outer' }))._unsafeUnwrap(); const inner = (await contextFor(outer.sdkDirectory().join('typescript')).repository())!; - expect((await inner.inspect(TYPESCRIPT))._unsafeUnwrapErr().kind).to.equal('nestedSdkDirectory'); + expect((await inner.inspect(TYPESCRIPT, undefined))._unsafeUnwrapErr().kind).to.equal('nestedSdkDirectory'); + }); + }); + + describe('sharing', () => { + const sh = (cwd: DirectoryPath | string, ...args: string[]) => + execa('git', ['-c', 'user.name=t', '-c', 'user.email=t@t', ...args], { cwd: cwd.toString() }); + + const withOrigin = async (): Promise<{ repository: SdkRepositoryContext; origin: DirectoryPath }> => { + const origin = new DirectoryPath(path.join(root, 'origin.git')); + await sh(root, 'init', '-q', '--bare', origin.toString()); + const repository = await project(); + await sh(repository.root(), 'remote', 'add', 'origin', origin.toString()); + return { repository, origin }; + }; + + /** A recorded generation, committed and pushed on `main`, as a developer leaves a project for a teammate. */ + const published = async (repository: SdkRepositoryContext) => { + const tree = (await repository.snapshotGenerated(generated({ 'README.md': '# v1' })))._unsafeUnwrap(); + const record = (await repository.recordGeneration(tree, TYPESCRIPT, '0.0.0', []))._unsafeUnwrap(); + await sh(repository.root(), 'add', '.'); + await sh(repository.root(), 'commit', '-qm', 'sdk'); + await sh(repository.root(), 'push', '-q', 'origin', 'main'); + return record.pristineCommit(); + }; + + const cloneOf = async (origin: DirectoryPath, name: string): Promise => { + const clone = new DirectoryPath(path.join(root, name)); + await sh(root, 'clone', '-q', '--no-local', origin.toString(), clone.toString()); + return (await contextFor(clone.join('sdk', 'typescript')).repository())!; + }; + + it('pushes the chain, then finds it already there', async () => { + const { repository, origin } = await withOrigin(); + const commit = (await record(repository, { 'README.md': '# v1' }))._unsafeUnwrap(); + + expect((await repository.shareSnapshot('origin'))._unsafeUnwrap()).to.equal('pushed'); + expect((await repository.shareSnapshot('origin'))._unsafeUnwrap()).to.equal('upToDate'); + + expect((await git.resolveRef(origin, repository.pristineRef()))?.isEqual(commit)).to.be.true; + }); + + it('lets a clone merge against a snapshot it fetched, and says where from', async () => { + const { repository, origin } = await withOrigin(); + const base = await published(repository); + const clone = await cloneOf(origin, 'clone'); + const before = (await clone.inspect(TYPESCRIPT, 'origin'))._unsafeUnwrap(); + expect(before).to.deep.include({ kind: 'snapshotMissing', fetch: { kind: 'absent', remote: 'origin' } }); + + (await repository.shareSnapshot('origin'))._unsafeUnwrap(); + const after = (await clone.inspect(TYPESCRIPT, 'origin'))._unsafeUnwrap(); + + expect(after.kind === 'ready' && after.base.isEqual(base) && after.fetchedFrom).to.equal('origin'); + expect((await git.resolveRef(clone.root(), clone.pristineRef()))?.isEqual(base)).to.be.true; + }); + + it('reports a remote it cannot reach, and no remote at all', async () => { + const { repository, origin } = await withOrigin(); + await published(repository); + const clone = await cloneOf(origin, 'clone'); + fs.rmSync(origin.toString(), { recursive: true, force: true }); + + const unreachable = (await clone.inspect(TYPESCRIPT, 'origin'))._unsafeUnwrap(); + const none = (await clone.inspect(TYPESCRIPT, undefined))._unsafeUnwrap(); + + expect(unreachable.kind === 'snapshotMissing' && unreachable.fetch).to.include({ + kind: 'failed', + remote: 'origin' + }); + expect(none.kind === 'snapshotMissing' && none.fetch).to.deep.equal({ kind: 'noRemote' }); + }); + + it('joins two chains that diverged, so both machines’ snapshots reach the remote', async () => { + const { repository: a, origin } = await withOrigin(); + const first = (await record(a, { 'README.md': '# a' }))._unsafeUnwrap(); + (await a.shareSnapshot('origin'))._unsafeUnwrap(); + const bRoot = new DirectoryPath(path.join(root, 'b')); + fs.mkdirSync(bRoot.join('sdk', 'typescript').toString(), { recursive: true }); + await git.init(bRoot); + await sh(bRoot, 'remote', 'add', 'origin', origin.toString()); + const b = (await contextFor(bRoot.join('sdk', 'typescript')).repository())!; + const second = (await record(b, { 'README.md': '# b' }))._unsafeUnwrap(); + + expect((await b.shareSnapshot('origin'))._unsafeUnwrap()).to.equal('joined'); + + const tip = (await git.resolveRef(origin, b.pristineRef()))!; + expect((await git.describe(origin, tip)).parents.map(String)).to.have.members([ + second.toString(), + first.toString() + ]); + expect((await git.describe(origin, tip)).subject).to.equal('apimatic: pristine chains joined'); + }); + + it('reports a push the remote refuses, with its reason', async () => { + const { repository, origin } = await withOrigin(); + fs.writeFileSync( + path.join(origin.toString(), 'hooks', 'pre-receive'), + '#!/bin/sh\necho "snapshots are not accepted here"\nexit 1\n', + { mode: 0o755 } + ); + (await record(repository, { 'README.md': '# v1' }))._unsafeUnwrap(); + + const shared = await repository.shareSnapshot('origin'); + + expect(shared._unsafeUnwrapErr().reason).to.contain('snapshots are not accepted here'); + expect(shared._unsafeUnwrapErr().reason).to.not.contain('failed to push some refs'); }); }); }); From 270f6e8d6980f519618896d9599b39a59f997804 Mon Sep 17 00:00:00 2001 From: Asad Ali <14asadali@gmail.com> Date: Wed, 21 Oct 2026 15:24:58 +0500 Subject: [PATCH 13/29] refactor(sdk): keep the prompts to wording The SDK prompts took SdkRepositoryContext, decided which conflicts leave markers, and built git refspecs themselves. They now take the directory and refs they print; MergePlan answers hasMarkedConflicts and hasUnmarkedConflicts and names the generator's files and yours; and SnapshotRef gives its refspecs, which GitService.pushRef uses too. What the prompts print is unchanged. Co-Authored-By: Claude Opus 5.5 --- src/actions/sdk/prepare.ts | 9 +++++-- src/actions/sdk/write.ts | 6 ++--- src/infrastructure/git-service.ts | 4 +-- src/prompts/sdk/prepare.ts | 18 ++++++------- src/prompts/sdk/write.ts | 40 ++++++++++++----------------- src/types/sdk/merge-plan.ts | 25 +++++++++++++++--- src/types/sdk/snapshot-ref.ts | 10 ++++++++ test/actions/sdk/write.test.ts | 4 ++- test/prompts/sdk/write.test.ts | 3 +-- test/types/sdk/merge-plan.test.ts | 15 ++++++++++- test/types/sdk/snapshot-ref.test.ts | 9 +++++++ 11 files changed, 97 insertions(+), 46 deletions(-) diff --git a/src/actions/sdk/prepare.ts b/src/actions/sdk/prepare.ts index 5acc0b07a..09ce87e7d 100644 --- a/src/actions/sdk/prepare.ts +++ b/src/actions/sdk/prepare.ts @@ -70,13 +70,18 @@ export class SdkPrepareAction { this.prompts.recordNotForThisGeneration(state.value.recorded, generation, sdkContext.directory()); return ActionResult.success({ kind: 'record', repository, remote }); case 'snapshotMissing': - this.prompts.snapshotMissing(state.value.commit, repository, state.value.fetch); + this.prompts.snapshotMissing( + state.value.commit, + repository.root(), + repository.pristineRef(), + state.value.fetch + ); return ActionResult.success({ kind: 'record', repository, remote }); case 'unresolved': if (state.value.fetchedFrom !== undefined) { this.prompts.snapshotsFetched(state.value.fetchedFrom); } - this.prompts.conflictsUnresolved(state.value.paths, repository); + this.prompts.conflictsUnresolved(state.value.paths, repository.sdkDirectory()); return ActionResult.failed(); case 'ready': if (state.value.fetchedFrom !== undefined) { diff --git a/src/actions/sdk/write.ts b/src/actions/sdk/write.ts index 2bbcb40c7..8a20865a3 100644 --- a/src/actions/sdk/write.ts +++ b/src/actions/sdk/write.ts @@ -101,7 +101,7 @@ export class SdkWriteAction { return this.customCodeFailed(applied.error); } await this.record(repository, generated, generation, plan.conflictedPaths(), remote); - this.prompts.sdkMerged(repository, plan); + this.prompts.sdkMerged(repository.sdkDirectory(), plan); if (!plan.hasConflicts()) { return ActionResult.success(); } @@ -109,7 +109,7 @@ export class SdkWriteAction { if (staged.isErr()) { this.prompts.conflictsNotStaged(staged.error.reason); } - this.prompts.conflicts(plan.conflicts, repository); + this.prompts.conflicts(plan, repository.sdkDirectory(), repository.previousRef()); return ActionResult.failed(MERGE_NEEDS_RESOLVING, plan); } @@ -141,7 +141,7 @@ export class SdkWriteAction { } const shared = await repository.shareSnapshot(remote); if (shared.isErr()) { - this.prompts.snapshotNotShared(remote, shared.error.reason, repository); + this.prompts.snapshotNotShared(remote, shared.error.reason, repository.pristineRef()); } else if (shared.value !== 'upToDate') { this.prompts.snapshotShared(remote, shared.value === 'joined'); } diff --git a/src/infrastructure/git-service.ts b/src/infrastructure/git-service.ts index 3d153167e..2e697dffb 100644 --- a/src/infrastructure/git-service.ts +++ b/src/infrastructure/git-service.ts @@ -320,8 +320,8 @@ export class GitService { /** Sends `ref` to `remote` as a fast-forward; a ref that moved on at the remote is reported as rejected, never forced. */ public async pushRef(root: DirectoryPath, remote: string, ref: SnapshotRef): Promise { - const result = await this.runRemote(root, ['push', '--porcelain', remote, `${ref}:${ref}`]); - const status = result.stdout.split('\n').find((line) => line.includes(`${ref}:${ref}`)); + const result = await this.runRemote(root, ['push', '--porcelain', remote, ref.refspec()]); + const status = result.stdout.split('\n').find((line) => line.includes(ref.refspec())); if (status === undefined) { throw new GitError(`git push failed: ${result.stderr.trim() || `exit code ${result.exitCode}`}`); } diff --git a/src/prompts/sdk/prepare.ts b/src/prompts/sdk/prepare.ts index 098f33a05..f1745390c 100644 --- a/src/prompts/sdk/prepare.ts +++ b/src/prompts/sdk/prepare.ts @@ -4,7 +4,8 @@ import { RelativeFilePath } from '../../types/file/relativeFilePath.js'; import { CommitId } from '../../types/sdk/commit-id.js'; import { describeGeneration, SdkGeneration } from '../../types/sdk/generation-record.js'; import { GitVersion } from '../../types/sdk/git-version.js'; -import { SdkRepositoryContext, SnapshotFetch } from '../../types/sdk-repository-context.js'; +import { SnapshotFetch } from '../../types/sdk-repository-context.js'; +import { SnapshotRef } from '../../types/sdk/snapshot-ref.js'; import { format as f } from '../format.js'; const plural = (count: number, noun: string) => `${count} ${noun}${count === 1 ? '' : 's'}`; @@ -51,11 +52,10 @@ export class SdkPreparePrompts { log.warn(message); } - public snapshotMissing(commit: CommitId, repository: SdkRepositoryContext, fetch: SnapshotFetch) { - const ref = repository.pristineRef().toString(); + public snapshotMissing(commit: CommitId, root: DirectoryPath, ref: SnapshotRef, fetch: SnapshotFetch) { const message = `The snapshot this SDK was generated from, commit ${f.var(commit.toString())}, is not in the repository at ` + - `${f.relativePath(repository.root())}, so your customizations cannot be merged. ${whereItWent(fetch)}\n` + + `${f.relativePath(root)}, so your customizations cannot be merged. ${whereItWent(fetch)}\n` + `${howToShare(fetch, ref)}\n` + `Otherwise the SDK will be replaced and a fresh snapshot recorded.`; log.warn(message); @@ -65,12 +65,12 @@ export class SdkPreparePrompts { log.info(`Fetched the SDK's snapshots from ${f.var(remote)}.`); } - public conflictsUnresolved(paths: readonly RelativeFilePath[], repository: SdkRepositoryContext) { + public conflictsUnresolved(paths: readonly RelativeFilePath[], sdk: DirectoryPath) { const one = paths.length === 1; const message = `${plural(paths.length, 'file')} still hold${one ? 's' : ''} conflict markers from the last run. ` + `Resolve ${one ? 'it' : 'them'} and remove the markers, then rerun:\n` + - paths.map((path) => ` ${f.relativePath(path.in(repository.sdkDirectory()))}`).join('\n'); + paths.map((path) => ` ${f.relativePath(path.in(sdk))}`).join('\n'); log.error(message); } } @@ -86,15 +86,15 @@ function whereItWent(fetch: SnapshotFetch): string { } } -function howToShare(fetch: SnapshotFetch, ref: string): string { +function howToShare(fetch: SnapshotFetch, ref: SnapshotRef): string { const remote = fetch.kind === 'noRemote' ? '' : fetch.remote; return ( `Whoever generated it shares it by regenerating with a remote, or with ${f.cmd( 'git', 'push', remote, - `${ref}:${ref}` + ref.refspec() )}; ` + - `then rerun here${fetch.kind === 'noRemote' ? ` after ${f.cmd('git', 'fetch', remote, `+${ref}:${ref}`)}` : ''}.` + `then rerun here${fetch.kind === 'noRemote' ? ` after ${f.cmd('git', 'fetch', remote, ref.forcedRefspec())}` : ''}.` ); } diff --git a/src/prompts/sdk/write.ts b/src/prompts/sdk/write.ts index 9893c62b0..8ce87009e 100644 --- a/src/prompts/sdk/write.ts +++ b/src/prompts/sdk/write.ts @@ -2,8 +2,8 @@ import { log } from '@clack/prompts'; import { DirectoryPath } from '../../types/file/directoryPath.js'; import { RelativeFilePath } from '../../types/file/relativeFilePath.js'; import { SdkSaveProblem } from '../../types/sdk-context.js'; -import { SdkRepositoryContext } from '../../types/sdk-repository-context.js'; -import { Conflict, ConflictKind, MergeFiles, MergePlan } from '../../types/sdk/merge-plan.js'; +import { ConflictKind, GENERATOR_FILES, MergePlan, YOUR_FILES } from '../../types/sdk/merge-plan.js'; +import { SnapshotRef } from '../../types/sdk/snapshot-ref.js'; import { listedInProse } from '../../utils/string-utils.js'; import { format as f } from '../format.js'; import { noteWrapped } from '../prompt.js'; @@ -17,19 +17,16 @@ const CONFLICT_LABELS: Record = { other: '' }; -const MARKED_KINDS: readonly ConflictKind[] = ['bothChanged', 'bothAdded']; - /** The generator's own files are a count; the developer's are named. */ -const GENERATOR_FILES: readonly (keyof MergeFiles)[] = ['added', 'updated', 'removed']; -const YOUR_FILES: Readonly>> = { +const YOUR_FILE_HEADINGS: Record<(typeof YOUR_FILES)[number], string> = { merged: 'Merged with your changes:', kept: 'Kept as you edited:' }; const plural = (count: number, noun: string) => `${count} ${noun}${count === 1 ? '' : 's'}`; -const listed = (repository: SdkRepositoryContext, paths: readonly RelativeFilePath[]) => - paths.map((relative) => ` ${f.relativePath(relative.in(repository.sdkDirectory()))}`); +const listed = (sdk: DirectoryPath, paths: readonly RelativeFilePath[]) => + paths.map((relative) => ` ${f.relativePath(relative.in(sdk))}`); export class SdkWritePrompts { public sdkNotSaved(problem: SdkSaveProblem) { @@ -72,16 +69,15 @@ export class SdkWritePrompts { log.info(message); } - public snapshotNotShared(remote: string, reason: string, repository: SdkRepositoryContext) { - const ref = repository.pristineRef().toString(); + public snapshotNotShared(remote: string, reason: string, ref: SnapshotRef) { const message = `The SDK's snapshot could not be pushed to ${f.var(remote)}: ${reason}\n` + `A clone will replace the SDK instead of merging until it is shared; ` + - `${f.cmd('git', 'push', remote, `${ref}:${ref}`)} does it by hand.`; + `${f.cmd('git', 'push', remote, ref.refspec())} does it by hand.`; log.warn(message); } - public sdkMerged(repository: SdkRepositoryContext, plan: MergePlan) { + public sdkMerged(sdk: DirectoryPath, plan: MergePlan) { const generated = GENERATOR_FILES.filter((kind) => plan.files[kind].length > 0).map( (kind) => `${plural(plan.files[kind].length, 'file')} ${kind}` ); @@ -89,24 +85,22 @@ export class SdkWritePrompts { generated.push(`${plural(plan.conflicts.length, 'file')} in conflict`); } const summary = listedInProse(generated) || 'nothing changed'; - const lines = [`Regenerated the SDK at ${f.relativePath(repository.sdkDirectory())}: ${summary}.`]; - for (const [kind, heading] of Object.entries(YOUR_FILES) as [keyof MergeFiles, string][]) { + const lines = [`Regenerated the SDK at ${f.relativePath(sdk)}: ${summary}.`]; + for (const kind of YOUR_FILES) { if (plan.files[kind].length > 0) { - lines.push(heading, ...listed(repository, plan.files[kind])); + lines.push(YOUR_FILE_HEADINGS[kind], ...listed(sdk, plan.files[kind])); } } log.info(lines.join('\n')); } - public conflicts(conflicts: readonly Conflict[], repository: SdkRepositoryContext) { + public conflicts(plan: MergePlan, sdk: DirectoryPath, previous: SnapshotRef) { + const { conflicts } = plan; const lines = conflicts.map( - (conflict) => - `${f.relativePath(conflict.path.in(repository.sdkDirectory()))}\n ${ - conflict.message ?? CONFLICT_LABELS[conflict.kind] - }` + (conflict) => `${f.relativePath(conflict.path.in(sdk))}\n ${conflict.message ?? CONFLICT_LABELS[conflict.kind]}` ); - const marked = conflicts.some((conflict) => MARKED_KINDS.includes(conflict.kind)); - const unmarked = conflicts.some((conflict) => !MARKED_KINDS.includes(conflict.kind)); + const marked = plan.hasMarkedConflicts(); + const unmarked = plan.hasUnmarkedConflicts(); const steps = [ ...(marked ? [ @@ -122,7 +116,7 @@ export class SdkWritePrompts { ] : []), 'Rerun the command; nothing needs committing first.', - `The directory as it was before this run is under ${f.var(repository.previousRef().toString())}.` + `The directory as it was before this run is under ${f.var(previous.toString())}.` ]; noteWrapped( [...lines, '', ...steps.map((step, index) => `${index + 1}. ${step}`)].join('\n'), diff --git a/src/types/sdk/merge-plan.ts b/src/types/sdk/merge-plan.ts index 36f984e18..600ee5a2b 100644 --- a/src/types/sdk/merge-plan.ts +++ b/src/types/sdk/merge-plan.ts @@ -69,6 +69,12 @@ export type MergeFiles = { unchanged: readonly RelativeFilePath[]; }; +/** The `MergeFiles` holding files you never edited, which only the generator changed. */ +export const GENERATOR_FILES = ['added', 'updated', 'removed'] as const; + +/** The `MergeFiles` holding files you edited. */ +export const YOUR_FILES = ['merged', 'kept'] as const; + const CONFLICT_PREFIX = 'CONFLICT ('; /** The stronger of two accounts of one path, when git reports it more than once. */ @@ -120,6 +126,11 @@ function conflictsOf(merge: MergeTreeOutput): Conflict[] { return [...byPath.values()]; } +/** The conflicts whose file is written with markers in it. */ +const MARKED_KINDS: readonly ConflictKind[] = ['bothChanged', 'bothAdded']; + +const leavesMarkers = (conflict: Conflict): boolean => MARKED_KINDS.includes(conflict.kind); + /** Each change under every path it names, so a rename is found by either name. */ const changedPaths = (changes: readonly Change[]): Map => new Map( @@ -250,10 +261,18 @@ export class MergePlan { return counts; } + /** Whether any conflicted file is written with conflict markers in it. */ + public hasMarkedConflicts(): boolean { + return this.conflicts.some(leavesMarkers); + } + + /** Whether any conflicted file is left without markers, for you to keep or remove. */ + public hasUnmarkedConflicts(): boolean { + return this.conflicts.some((conflict) => !leavesMarkers(conflict)); + } + /** Whether the file at `path` is written with conflict markers in it. */ public hasMarkers(path: RelativeFilePath): boolean { - return this.conflicts.some( - (conflict) => conflict.path.isEqual(path) && (conflict.kind === 'bothChanged' || conflict.kind === 'bothAdded') - ); + return this.conflicts.some((conflict) => conflict.path.isEqual(path) && leavesMarkers(conflict)); } } diff --git a/src/types/sdk/snapshot-ref.ts b/src/types/sdk/snapshot-ref.ts index 7af3d826f..68f82c62f 100644 --- a/src/types/sdk/snapshot-ref.ts +++ b/src/types/sdk/snapshot-ref.ts @@ -33,6 +33,16 @@ export class SnapshotRef { ); } + /** This ref onto itself, as `git push` and `git fetch` take it to move the ref between repositories. */ + public refspec(): string { + return `${this.name}:${this.name}`; + } + + /** As `refspec`, but taking the other side's ref even where it moved on. */ + public forcedRefspec(): string { + return `+${this.name}:${this.name}`; + } + public isEqual(other: SnapshotRef): boolean { return this.name === other.name; } diff --git a/test/actions/sdk/write.test.ts b/test/actions/sdk/write.test.ts index 7d4ce45a8..e7c8e35ad 100644 --- a/test/actions/sdk/write.test.ts +++ b/test/actions/sdk/write.test.ts @@ -220,7 +220,9 @@ describe('SdkWriteAction', () => { expect(result.isFailed()).to.be.true; expect(result.getExitCode()).to.equal(1); expect(result.getMessage()).to.equal('Resolve the conflicts and rerun'); - expect(conflicts.firstCall.args[0]).to.deep.equal([{ path: rel('models/book.py'), kind: 'bothChanged' }]); + expect(conflicts.firstCall.args[0].conflicts).to.deep.equal([ + { path: rel('models/book.py'), kind: 'bothChanged' } + ]); expect(read('models/book.py')).to.contain(`${YOURS_MARKER}\n # Price in USD, VAT included.\n=======\n`); expect(read('models/book.py')).to.contain(' # Retail price in US dollars.\n'); expect(read('models/book.py')).to.contain(`${GENERATED_MARKER}\n`); diff --git a/test/prompts/sdk/write.test.ts b/test/prompts/sdk/write.test.ts index 76b50f075..a8d69d32d 100644 --- a/test/prompts/sdk/write.test.ts +++ b/test/prompts/sdk/write.test.ts @@ -5,7 +5,6 @@ import { log } from '@clack/prompts'; import { SdkWritePrompts } from '../../../src/prompts/sdk/write.js'; import { DirectoryPath } from '../../../src/types/file/directoryPath.js'; import { RelativeFilePath } from '../../../src/types/file/relativeFilePath.js'; -import { SdkRepositoryContext } from '../../../src/types/sdk-repository-context.js'; import { ConflictKind, MergePlan } from '../../../src/types/sdk/merge-plan.js'; const rel = (value: string) => RelativeFilePath.fromGit(value); @@ -32,7 +31,7 @@ const plan = ( describe('SdkWritePrompts.sdkMerged', () => { const root = new DirectoryPath(process.cwd()); - const sdk = SdkRepositoryContext.create(root, root.join('sdk', 'typescript'))!; + const sdk = root.join('sdk', 'typescript'); afterEach(() => { sinon.restore(); diff --git a/test/types/sdk/merge-plan.test.ts b/test/types/sdk/merge-plan.test.ts index 8ff382256..d988ecfaf 100644 --- a/test/types/sdk/merge-plan.test.ts +++ b/test/types/sdk/merge-plan.test.ts @@ -1,6 +1,6 @@ import { RelativeFilePath } from '../../../src/types/file/relativeFilePath.js'; import { expect } from 'chai'; -import { MergeInputs, MergePlan, MergeTreeOutput } from '../../../src/types/sdk/merge-plan.js'; +import { ConflictKind, MergeInputs, MergePlan, MergeTreeOutput } from '../../../src/types/sdk/merge-plan.js'; import { TreeId } from '../../../src/types/sdk/tree-id.js'; const TREE = TreeId.fromGit('t'.repeat(40)); @@ -131,6 +131,19 @@ describe('MergePlan.from', () => { expect(counts(plan)).to.deep.equal({ added: 0, updated: 0, merged: 0, removed: 0, kept: 0, unchanged: 0 }); }); + it('tells the conflicts that leave markers in the file from those that leave it whole', () => { + const marks = (...kinds: ConflictKind[]) => { + const conflicts = kinds.map((kind, index) => ({ path: rel(`${index}.ts`), kind })); + const plan = new MergePlan([], [], conflicts, [], MergePlan.from(inputs({})).files); + return [plan.hasMarkedConflicts(), plan.hasUnmarkedConflicts()]; + }; + + expect(marks('bothChanged', 'bothAdded')).to.deep.equal([true, false]); + expect(marks('deletedByYou', 'noLongerGenerated', 'binary', 'other')).to.deep.equal([false, true]); + expect(marks('bothChanged', 'binary')).to.deep.equal([true, true]); + expect(marks()).to.deep.equal([false, false]); + }); + it('reports the renames each side made', () => { const plan = MergePlan.from( inputs({ diff --git a/test/types/sdk/snapshot-ref.test.ts b/test/types/sdk/snapshot-ref.test.ts index d41c09a7f..6a50663bc 100644 --- a/test/types/sdk/snapshot-ref.test.ts +++ b/test/types/sdk/snapshot-ref.test.ts @@ -8,6 +8,15 @@ describe('SnapshotRef', () => { expect(SnapshotRef.pristine('').toString()).to.equal('refs/apimatic/pristine'); }); + it('maps itself onto itself for push and fetch, forced when the other side may have moved on', () => { + const ref = SnapshotRef.pristine('sdk/typescript'); + + expect(ref.refspec()).to.equal('refs/apimatic/pristine/sdk/typescript:refs/apimatic/pristine/sdk/typescript'); + expect(ref.forcedRefspec()).to.equal( + '+refs/apimatic/pristine/sdk/typescript:refs/apimatic/pristine/sdk/typescript' + ); + }); + it('collides only with a ref on its own path, in either direction', () => { const typescript = SnapshotRef.pristine('sdk/typescript'); From 3596340cb4cc3d079bc9d4a4f36ad0b59f4b107f Mon Sep 17 00:00:00 2001 From: Asad Ali <14asadali@gmail.com> Date: Wed, 21 Oct 2026 15:28:36 +0500 Subject: [PATCH 14/29] refactor(sdk): move the git types to src/types/git CommitId, TreeId and GitVersion know nothing of SDKs, and the shapes GitService reads from diff-tree and merge-tree (Change, StageEntry, MergeMessage, MergeTreeOutput) sat in merge-plan.ts, so the git service imported its own output types from an SDK file. They move to src/types/git/, the four shapes as merge-tree.ts, where StageEntry names its stages ours and theirs as git does. SnapshotRef, the conflict markers and MergePlan stay in src/types/sdk/: they name the SDK's snapshots, your side and the generator's. Co-Authored-By: Claude Opus 5.5 --- src/actions/sdk/write.ts | 4 ++-- src/commands/sdk/generate.ts | 2 +- src/infrastructure/git-service.ts | 8 ++++---- src/prompts/sdk/prepare.ts | 4 ++-- src/types/{sdk => git}/commit-id.ts | 0 src/types/{sdk => git}/git-version.ts | 0 src/types/git/merge-tree.ts | 21 +++++++++++++++++++++ src/types/{sdk => git}/tree-id.ts | 0 src/types/sdk-repository-context.ts | 7 ++++--- src/types/sdk/generation-record.ts | 2 +- src/types/sdk/merge-plan.ts | 21 +-------------------- src/types/sdk/sdk-destination.ts | 2 +- test/actions/sdk/prepare.test.ts | 4 ++-- test/actions/sdk/write.test.ts | 2 +- test/helpers/snapshots.ts | 2 +- test/infrastructure/git-service.test.ts | 2 +- test/types/{sdk => git}/git-version.test.ts | 2 +- test/types/sdk-repository-context.test.ts | 2 +- test/types/sdk/generation-record.test.ts | 2 +- test/types/sdk/merge-plan.test.ts | 5 +++-- 20 files changed, 48 insertions(+), 44 deletions(-) rename src/types/{sdk => git}/commit-id.ts (100%) rename src/types/{sdk => git}/git-version.ts (100%) create mode 100644 src/types/git/merge-tree.ts rename src/types/{sdk => git}/tree-id.ts (100%) rename test/types/{sdk => git}/git-version.test.ts (92%) diff --git a/src/actions/sdk/write.ts b/src/actions/sdk/write.ts index 8a20865a3..3ae90ee28 100644 --- a/src/actions/sdk/write.ts +++ b/src/actions/sdk/write.ts @@ -3,13 +3,13 @@ import { envInfo } from '../../infrastructure/env-info.js'; import { SdkWritePrompts } from '../../prompts/sdk/write.js'; import { DirectoryPath } from '../../types/file/directoryPath.js'; import { RelativeFilePath } from '../../types/file/relativeFilePath.js'; +import { CommitId } from '../../types/git/commit-id.js'; +import { TreeId } from '../../types/git/tree-id.js'; import { SdkContext } from '../../types/sdk-context.js'; import { SdkRepositoryContext, SdkRepositoryProblem } from '../../types/sdk-repository-context.js'; -import { CommitId } from '../../types/sdk/commit-id.js'; import { SdkGeneration } from '../../types/sdk/generation-record.js'; import { MergePlan } from '../../types/sdk/merge-plan.js'; import { SdkDestination } from '../../types/sdk/sdk-destination.js'; -import { TreeId } from '../../types/sdk/tree-id.js'; import { ActionResult } from '../action-result.js'; const MERGE_NEEDS_RESOLVING = 'Resolve the conflicts and rerun'; diff --git a/src/commands/sdk/generate.ts b/src/commands/sdk/generate.ts index a0920b3f3..d91d8f76e 100644 --- a/src/commands/sdk/generate.ts +++ b/src/commands/sdk/generate.ts @@ -10,7 +10,7 @@ import { Stability, UPCOMING_LANGUAGES } from '../../types/sdk/generate.js'; -import { GitVersion } from '../../types/sdk/git-version.js'; +import { GitVersion } from '../../types/git/git-version.js'; import { StabilityChoice } from '../../types/sdk/stability-choice.js'; import { CommandMetadata } from '../../types/common/command-metadata.js'; import { ProjectContext } from '../../types/project-context.js'; diff --git a/src/infrastructure/git-service.ts b/src/infrastructure/git-service.ts index 2e697dffb..0e23cf5f5 100644 --- a/src/infrastructure/git-service.ts +++ b/src/infrastructure/git-service.ts @@ -6,11 +6,11 @@ import { RelativeFilePath } from '../types/file/relativeFilePath.js'; import { DirectoryPath } from '../types/file/directoryPath.js'; import { FileName } from '../types/file/fileName.js'; import { FilePath } from '../types/file/filePath.js'; -import { CommitId } from '../types/sdk/commit-id.js'; -import { GitVersion } from '../types/sdk/git-version.js'; -import { Change, MergeMessage, MergeTreeOutput, StageEntry } from '../types/sdk/merge-plan.js'; +import { CommitId } from '../types/git/commit-id.js'; +import { GitVersion } from '../types/git/git-version.js'; +import { Change, MergeMessage, MergeTreeOutput, StageEntry } from '../types/git/merge-tree.js'; +import { TreeId } from '../types/git/tree-id.js'; import { SnapshotRef } from '../types/sdk/snapshot-ref.js'; -import { TreeId } from '../types/sdk/tree-id.js'; import { errorMessage } from '../utils/error-utils.js'; import { withDirPath } from './tmp-extensions.js'; diff --git a/src/prompts/sdk/prepare.ts b/src/prompts/sdk/prepare.ts index f1745390c..8442646da 100644 --- a/src/prompts/sdk/prepare.ts +++ b/src/prompts/sdk/prepare.ts @@ -1,9 +1,9 @@ import { isCancel, confirm, log } from '@clack/prompts'; import { DirectoryPath } from '../../types/file/directoryPath.js'; import { RelativeFilePath } from '../../types/file/relativeFilePath.js'; -import { CommitId } from '../../types/sdk/commit-id.js'; +import { CommitId } from '../../types/git/commit-id.js'; +import { GitVersion } from '../../types/git/git-version.js'; import { describeGeneration, SdkGeneration } from '../../types/sdk/generation-record.js'; -import { GitVersion } from '../../types/sdk/git-version.js'; import { SnapshotFetch } from '../../types/sdk-repository-context.js'; import { SnapshotRef } from '../../types/sdk/snapshot-ref.js'; import { format as f } from '../format.js'; diff --git a/src/types/sdk/commit-id.ts b/src/types/git/commit-id.ts similarity index 100% rename from src/types/sdk/commit-id.ts rename to src/types/git/commit-id.ts diff --git a/src/types/sdk/git-version.ts b/src/types/git/git-version.ts similarity index 100% rename from src/types/sdk/git-version.ts rename to src/types/git/git-version.ts diff --git a/src/types/git/merge-tree.ts b/src/types/git/merge-tree.ts new file mode 100644 index 000000000..790a213ce --- /dev/null +++ b/src/types/git/merge-tree.ts @@ -0,0 +1,21 @@ +import { RelativeFilePath } from '../file/relativeFilePath.js'; +import { TreeId } from './tree-id.js'; + +/** One line of `diff-tree --raw`: how `path` differs between two trees, and the blob it holds in the second. */ +export type Change = { + status: 'A' | 'M' | 'D' | 'T' | 'R' | 'C'; + path: RelativeFilePath; + /** The path in the first tree, for a rename or copy. */ + from?: RelativeFilePath; + /** The blob in the second tree; absent for a deletion. */ + blob?: string; +}; + +/** One conflicted path's version on one side: stage 1 is the base, 2 ours, 3 theirs. */ +export type StageEntry = { mode: string; oid: string; stage: 1 | 2 | 3; path: RelativeFilePath }; + +/** One of git's informational messages, such as `CONFLICT (modify/delete)` for a path. */ +export type MergeMessage = { paths: RelativeFilePath[]; type: string; message: string }; + +/** What `git merge-tree --write-tree` reports: the merged tree, the stages of every conflicted path, and its messages. */ +export type MergeTreeOutput = { tree: TreeId; stages: StageEntry[]; messages: MergeMessage[] }; diff --git a/src/types/sdk/tree-id.ts b/src/types/git/tree-id.ts similarity index 100% rename from src/types/sdk/tree-id.ts rename to src/types/git/tree-id.ts diff --git a/src/types/sdk-repository-context.ts b/src/types/sdk-repository-context.ts index e825a5141..3930df85b 100644 --- a/src/types/sdk-repository-context.ts +++ b/src/types/sdk-repository-context.ts @@ -8,12 +8,13 @@ import { DirectoryPath, posix } from './file/directoryPath.js'; import { FileName } from './file/fileName.js'; import { FilePath } from './file/filePath.js'; import { RelativeFilePath } from './file/relativeFilePath.js'; -import { CommitId } from './sdk/commit-id.js'; +import { CommitId } from './git/commit-id.js'; +import { StageEntry } from './git/merge-tree.js'; +import { TreeId } from './git/tree-id.js'; import { hasConflictMarkers, relabelMarkers } from './sdk/conflict-markers.js'; import { describeGeneration, GenerationRecord, SdkGeneration } from './sdk/generation-record.js'; -import { MergePlan, StageEntry } from './sdk/merge-plan.js'; +import { MergePlan } from './sdk/merge-plan.js'; import { SnapshotRef } from './sdk/snapshot-ref.js'; -import { TreeId } from './sdk/tree-id.js'; export type SdkRepositoryProblem = { kind: 'nestedSdkDirectory' | 'gitFailed' | 'recordNotWritten'; diff --git a/src/types/sdk/generation-record.ts b/src/types/sdk/generation-record.ts index 20eb49f3c..8a453fef1 100644 --- a/src/types/sdk/generation-record.ts +++ b/src/types/sdk/generation-record.ts @@ -1,5 +1,5 @@ import { RelativeFilePath } from '../file/relativeFilePath.js'; -import { CommitId } from './commit-id.js'; +import { CommitId } from '../git/commit-id.js'; import { CodeGenerationVersion, Language, Stability } from './generate.js'; /** What was generated: a snapshot is a base only for the same generator output. */ diff --git a/src/types/sdk/merge-plan.ts b/src/types/sdk/merge-plan.ts index 600ee5a2b..35731d37c 100644 --- a/src/types/sdk/merge-plan.ts +++ b/src/types/sdk/merge-plan.ts @@ -1,24 +1,5 @@ import { RelativeFilePath } from '../file/relativeFilePath.js'; -import { TreeId } from './tree-id.js'; - -/** One line of `diff-tree --raw`: how `path` differs between two trees, and the blob it holds in the second. */ -export type Change = { - status: 'A' | 'M' | 'D' | 'T' | 'R' | 'C'; - path: RelativeFilePath; - /** The path in the first tree, for a rename or copy. */ - from?: RelativeFilePath; - /** The blob in the second tree; absent for a deletion. */ - blob?: string; -}; - -/** One conflicted path's version on one side: stage 1 is the base, 2 yours, 3 the generator's. */ -export type StageEntry = { mode: string; oid: string; stage: 1 | 2 | 3; path: RelativeFilePath }; - -/** One of git's informational messages, such as `CONFLICT (modify/delete)` for a path. */ -export type MergeMessage = { paths: RelativeFilePath[]; type: string; message: string }; - -/** What `git merge-tree --write-tree` reports: the merged tree, the stages of every conflicted path, and its messages. */ -export type MergeTreeOutput = { tree: TreeId; stages: StageEntry[]; messages: MergeMessage[] }; +import { Change, MergeTreeOutput } from '../git/merge-tree.js'; export type MergeInputs = { merge: MergeTreeOutput; diff --git a/src/types/sdk/sdk-destination.ts b/src/types/sdk/sdk-destination.ts index ecb13b6d4..7f52a0c53 100644 --- a/src/types/sdk/sdk-destination.ts +++ b/src/types/sdk/sdk-destination.ts @@ -1,4 +1,4 @@ -import { CommitId } from './commit-id.js'; +import { CommitId } from '../git/commit-id.js'; import { SdkRepositoryContext } from '../sdk-repository-context.js'; /** diff --git a/test/actions/sdk/prepare.test.ts b/test/actions/sdk/prepare.test.ts index b1d5fd3df..817bbc850 100644 --- a/test/actions/sdk/prepare.test.ts +++ b/test/actions/sdk/prepare.test.ts @@ -10,11 +10,11 @@ import { SdkPrepareAction } from '../../../src/actions/sdk/prepare.js'; import { GitService } from '../../../src/infrastructure/git-service.js'; import { SdkPreparePrompts } from '../../../src/prompts/sdk/prepare.js'; import { DirectoryPath } from '../../../src/types/file/directoryPath.js'; +import { CommitId } from '../../../src/types/git/commit-id.js'; +import { GitVersion } from '../../../src/types/git/git-version.js'; import { SdkContext } from '../../../src/types/sdk-context.js'; -import { CommitId } from '../../../src/types/sdk/commit-id.js'; import { CodeGenerationVersion, Language, Stability } from '../../../src/types/sdk/generate.js'; import { GenerationRecord, SdkGeneration } from '../../../src/types/sdk/generation-record.js'; -import { GitVersion } from '../../../src/types/sdk/git-version.js'; import { SdkRepositoryContext } from '../../../src/types/sdk-repository-context.js'; const STABLE: SdkGeneration = { diff --git a/test/actions/sdk/write.test.ts b/test/actions/sdk/write.test.ts index e7c8e35ad..c20da357d 100644 --- a/test/actions/sdk/write.test.ts +++ b/test/actions/sdk/write.test.ts @@ -11,8 +11,8 @@ import { GitService } from '../../../src/infrastructure/git-service.js'; import { ZipService } from '../../../src/infrastructure/zip-service.js'; import { SdkWritePrompts } from '../../../src/prompts/sdk/write.js'; import { DirectoryPath } from '../../../src/types/file/directoryPath.js'; +import { CommitId } from '../../../src/types/git/commit-id.js'; import { SdkContext } from '../../../src/types/sdk-context.js'; -import { CommitId } from '../../../src/types/sdk/commit-id.js'; import { GENERATED_MARKER, YOURS_MARKER } from '../../../src/types/sdk/conflict-markers.js'; import { CodeGenerationVersion, Language, Stability } from '../../../src/types/sdk/generate.js'; import { GenerationRecord, SdkGeneration } from '../../../src/types/sdk/generation-record.js'; diff --git a/test/helpers/snapshots.ts b/test/helpers/snapshots.ts index 82943cea2..0011a5366 100644 --- a/test/helpers/snapshots.ts +++ b/test/helpers/snapshots.ts @@ -1,7 +1,7 @@ import { TextDecoder } from 'node:util'; import { GitService } from '../../src/infrastructure/git-service.js'; import { DirectoryPath } from '../../src/types/file/directoryPath.js'; -import { CommitId } from '../../src/types/sdk/commit-id.js'; +import { CommitId } from '../../src/types/git/commit-id.js'; /** The files a snapshot commit holds, by path below the SDK directory, as text. */ export async function snapshotFiles(root: DirectoryPath, commit: CommitId): Promise> { diff --git a/test/infrastructure/git-service.test.ts b/test/infrastructure/git-service.test.ts index 0d1014e1f..60daf6efa 100644 --- a/test/infrastructure/git-service.test.ts +++ b/test/infrastructure/git-service.test.ts @@ -7,7 +7,7 @@ import { expect } from 'chai'; import { execa } from 'execa'; import { GitService } from '../../src/infrastructure/git-service.js'; import { DirectoryPath } from '../../src/types/file/directoryPath.js'; -import { CommitId } from '../../src/types/sdk/commit-id.js'; +import { CommitId } from '../../src/types/git/commit-id.js'; import { SnapshotRef } from '../../src/types/sdk/snapshot-ref.js'; const text = (content: Uint8Array | undefined) => diff --git a/test/types/sdk/git-version.test.ts b/test/types/git/git-version.test.ts similarity index 92% rename from test/types/sdk/git-version.test.ts rename to test/types/git/git-version.test.ts index 1f87fea02..4227a0614 100644 --- a/test/types/sdk/git-version.test.ts +++ b/test/types/git/git-version.test.ts @@ -1,5 +1,5 @@ import { expect } from 'chai'; -import { GitVersion } from '../../../src/types/sdk/git-version.js'; +import { GitVersion } from '../../../src/types/git/git-version.js'; describe('GitVersion', () => { it('reads what git prints, on any platform', () => { diff --git a/test/types/sdk-repository-context.test.ts b/test/types/sdk-repository-context.test.ts index 4940f024a..bffb7a0f7 100644 --- a/test/types/sdk-repository-context.test.ts +++ b/test/types/sdk-repository-context.test.ts @@ -6,9 +6,9 @@ import { execa } from 'execa'; import { GitService } from '../../src/infrastructure/git-service.js'; import { DirectoryPath } from '../../src/types/file/directoryPath.js'; import { RelativeFilePath } from '../../src/types/file/relativeFilePath.js'; +import { CommitId } from '../../src/types/git/commit-id.js'; import { SdkContext } from '../../src/types/sdk-context.js'; import { SdkRepositoryContext } from '../../src/types/sdk-repository-context.js'; -import { CommitId } from '../../src/types/sdk/commit-id.js'; import { CodeGenerationVersion, Language, Stability } from '../../src/types/sdk/generate.js'; import { GenerationRecord, SdkGeneration } from '../../src/types/sdk/generation-record.js'; import { SnapshotRef } from '../../src/types/sdk/snapshot-ref.js'; diff --git a/test/types/sdk/generation-record.test.ts b/test/types/sdk/generation-record.test.ts index 5f2f74cf1..b67b61a38 100644 --- a/test/types/sdk/generation-record.test.ts +++ b/test/types/sdk/generation-record.test.ts @@ -1,6 +1,6 @@ import { RelativeFilePath } from '../../../src/types/file/relativeFilePath.js'; import { expect } from 'chai'; -import { CommitId } from '../../../src/types/sdk/commit-id.js'; +import { CommitId } from '../../../src/types/git/commit-id.js'; import { CodeGenerationVersion, Language, Stability } from '../../../src/types/sdk/generate.js'; import { GenerationRecord } from '../../../src/types/sdk/generation-record.js'; diff --git a/test/types/sdk/merge-plan.test.ts b/test/types/sdk/merge-plan.test.ts index d988ecfaf..f458d8c17 100644 --- a/test/types/sdk/merge-plan.test.ts +++ b/test/types/sdk/merge-plan.test.ts @@ -1,7 +1,8 @@ import { RelativeFilePath } from '../../../src/types/file/relativeFilePath.js'; import { expect } from 'chai'; -import { ConflictKind, MergeInputs, MergePlan, MergeTreeOutput } from '../../../src/types/sdk/merge-plan.js'; -import { TreeId } from '../../../src/types/sdk/tree-id.js'; +import { MergeTreeOutput } from '../../../src/types/git/merge-tree.js'; +import { TreeId } from '../../../src/types/git/tree-id.js'; +import { ConflictKind, MergeInputs, MergePlan } from '../../../src/types/sdk/merge-plan.js'; const TREE = TreeId.fromGit('t'.repeat(40)); const blob = (name: string) => name.padEnd(40, '0'); From 0fa08d3598cd7958e120fb5791dc675689e00052 Mon Sep 17 00:00:00 2001 From: Asad Ali <14asadali@gmail.com> Date: Wed, 21 Oct 2026 15:38:12 +0500 Subject: [PATCH 15/29] refactor(sdk): keep the closing text with the prompts The write action set its own closing line for a run left with conflicts; the wording now lives in the write prompts, which the action imports. Comments in the prepare and write actions are cut to one line each. Co-Authored-By: Claude Opus 5.5 --- src/actions/sdk/prepare.ts | 5 +---- src/actions/sdk/write.ts | 11 +++-------- src/prompts/sdk/write.ts | 3 +++ 3 files changed, 7 insertions(+), 12 deletions(-) diff --git a/src/actions/sdk/prepare.ts b/src/actions/sdk/prepare.ts index 09ce87e7d..fea441548 100644 --- a/src/actions/sdk/prepare.ts +++ b/src/actions/sdk/prepare.ts @@ -5,10 +5,7 @@ import { SdkGeneration } from '../../types/sdk/generation-record.js'; import { SdkDestination } from '../../types/sdk/sdk-destination.js'; import { ActionResult } from '../action-result.js'; -/** - * Decides how the SDK directory will be written, before anything is uploaded: a run that will be - * refused, or that the developer does not want, stops here. - */ +/** Decides how the SDK will be written before anything is uploaded, so a refused or declined run stops here. */ export class SdkPrepareAction { private readonly prompts = new SdkPreparePrompts(); private readonly git = new GitService(); diff --git a/src/actions/sdk/write.ts b/src/actions/sdk/write.ts index 3ae90ee28..59623fde7 100644 --- a/src/actions/sdk/write.ts +++ b/src/actions/sdk/write.ts @@ -1,6 +1,6 @@ import { err, ok, Result } from 'neverthrow'; import { envInfo } from '../../infrastructure/env-info.js'; -import { SdkWritePrompts } from '../../prompts/sdk/write.js'; +import { MERGE_NEEDS_RESOLVING, SdkWritePrompts } from '../../prompts/sdk/write.js'; import { DirectoryPath } from '../../types/file/directoryPath.js'; import { RelativeFilePath } from '../../types/file/relativeFilePath.js'; import { CommitId } from '../../types/git/commit-id.js'; @@ -12,8 +12,6 @@ import { MergePlan } from '../../types/sdk/merge-plan.js'; import { SdkDestination } from '../../types/sdk/sdk-destination.js'; import { ActionResult } from '../action-result.js'; -const MERGE_NEEDS_RESOLVING = 'Resolve the conflicts and rerun'; - /** Writes a freshly generated SDK into its directory the way the destination was prepared for. */ export class SdkWriteAction { private readonly prompts = new SdkWritePrompts(); @@ -44,8 +42,7 @@ export class SdkWriteAction { return ActionResult.success(); } - // The repository is made once the generation has succeeded, so a failed one leaves none behind, - // and before the write, which the generator's output cannot collide with. + // Made only once generation succeeds, so a failed run leaves no repository; generated files never hold a .git. private async overwriteAndRecord( enclosing: SdkRepositoryContext | undefined, remote: string | undefined, @@ -118,9 +115,7 @@ export class SdkWriteAction { return ActionResult.failed(); } - // The SDK is already on disk when this runs, so a failure here is a warning: the next run - // asks before replacing it, as it would any directory without a record. Sharing the snapshot - // is a warning too: the developer can push the ref by hand, and teammates ask until then. + // Only warns: the SDK is already on disk, and a run without its snapshot asks before replacing it. private async record( repository: SdkRepositoryContext, generated: Result, diff --git a/src/prompts/sdk/write.ts b/src/prompts/sdk/write.ts index 8ce87009e..5299bfee4 100644 --- a/src/prompts/sdk/write.ts +++ b/src/prompts/sdk/write.ts @@ -8,6 +8,9 @@ import { listedInProse } from '../../utils/string-utils.js'; import { format as f } from '../format.js'; import { noteWrapped } from '../prompt.js'; +/** The closing line of a run that left conflicts. */ +export const MERGE_NEEDS_RESOLVING = 'Resolve the conflicts and rerun'; + const CONFLICT_LABELS: Record = { bothChanged: 'changed by you and by the generator; markers in the file', bothAdded: 'added by you and by the generator; markers in the file', From c2dd9f2e3dc85997a2b60ba3aa97754827a02c01 Mon Sep 17 00:00:00 2001 From: Asad Ali <14asadali@gmail.com> Date: Wed, 21 Oct 2026 16:38:54 +0500 Subject: [PATCH 16/29] fix(sdk): keep the custom-code messages short and free of internals A missing snapshot, a refused push, a nested SDK directory, a generation at another stability and the conflict steps no longer show commit ids, ref names, git's status words or the manual commands; the README keeps the manual push and the undo command. "Pushed" is one line whether or not chains were joined, and the fetch is no longer announced. The data the prompts no longer need went with it: the missing snapshot's commit, where a base was fetched from, the joined push outcome, and the nested refusal's prose reason, which is now a NestedSdkDirectory naming the other directory and whether it encloses this one. Remote errors carry the server's own words. The description of two generations side by side lives with the generation type, so the prompts stay wording. Co-Authored-By: Claude Fable 5.1 --- .ai/plans/custom-code.md | 13 +++--- README.md | 11 +++-- src/actions/sdk/prepare.ts | 15 +------ src/actions/sdk/write.ts | 6 +-- src/infrastructure/git-service.ts | 27 ++++++------ src/prompts/sdk/prepare.ts | 52 +++++++--------------- src/prompts/sdk/write.ts | 27 ++++-------- src/types/sdk-repository-context.ts | 54 ++++++++++------------- src/types/sdk/generation-record.ts | 7 +++ src/types/sdk/snapshot-ref.ts | 6 +++ test/actions/sdk/prepare.test.ts | 22 ++++++--- test/actions/sdk/write.test.ts | 4 +- test/types/sdk-repository-context.test.ts | 23 +++++++--- 13 files changed, 128 insertions(+), 139 deletions(-) diff --git a/.ai/plans/custom-code.md b/.ai/plans/custom-code.md index 61375d950..7d709bd19 100644 --- a/.ai/plans/custom-code.md +++ b/.ai/plans/custom-code.md @@ -83,9 +83,10 @@ the ref has no suffix. The commit named in the generation record on disk, never the ref's tip. The tip only says where the chain continues. If the named commit is absent locally, the CLI fetches the ref from -the remote (3.2) and looks again; if it is still absent, it says which commit is missing, why -(no remote, the remote lacks it, or the fetch failed and how), how to push it by hand, and -offers to overwrite instead, which records a fresh snapshot. +the remote (3.2) and looks again; if it is still absent, it says so and why (no remote, the +remote lacks it, or the fetch failed and how), and offers to overwrite instead, which records a +fresh snapshot. The commit id and the ref are not shown: nothing in the message is for the +developer to act on beyond the question that follows. ### 3.2 Sharing through the remote @@ -103,9 +104,9 @@ reachable and the push is a fast-forward. Every remote command runs without prompts (`GIT_TERMINAL_PROMPT=0`, `credential.interactive=false`, ssh `BatchMode=yes` unless `GIT_SSH_COMMAND` is set) and with a 30-second timeout, so a missing credential, a refused push (no rights, a ruleset on -`refs/apimatic/*`, a hook) or a dead remote becomes a warning with git's reason and the -manual `git push :`, never a stuck or failed run. The SDK is written and -recorded either way; only the sharing is missing until someone pushes. +`refs/apimatic/*`, a hook) or a dead remote becomes a warning in the server's own words, +never a stuck or failed run. The SDK is written and recorded either way; only the sharing is +missing until someone pushes (the manual command is in the README, not in the message). Fallback by hand, when the CLI could not: `git push refs/apimatic/pristine/:refs/apimatic/pristine/` on the generating machine, and `git fetch +refs/apimatic/pristine/:refs/apimatic/pristine/` diff --git a/README.md b/README.md index a4369c2b6..42ac7470f 100644 --- a/README.md +++ b/README.md @@ -38,7 +38,11 @@ This needs git 2.38 or newer. On the first run, if no git repository encloses th with the SDK, and pushes the ref to the remote your branch pushes to (or `origin`), saying so when it did. A push that fails, for lack of access, a credential or a network, is a warning, not a failed run; your customizations are still kept locally. No branch is created, and no commit is needed before regenerating. -The SDK directory as it was before the last run is kept under `refs/apimatic/previous/`. +The SDK directory as it was before the last run is kept under `refs/apimatic/previous/`; to undo a run: + +```sh-session +$ git --work-tree= restore --source=refs/apimatic/previous/ --worktree -- . +``` When you and the generator changed the same lines, the file gets git-style conflict markers labelled `Your changes` and `Generated by APIMatic`, the run lists the files and exits with code 1, and `git status` @@ -47,8 +51,9 @@ to run. Every other file is already updated. A clone or a CI job has the record but not the hidden ref, so the CLI fetches the ref from the remote before it merges. If the remote does not have it either, because the push from the generating machine failed, the CLI says -which commit is missing and how to push it by hand, then asks before replacing the SDK (or replaces it with -`--force`) and records a fresh snapshot. Two machines that regenerate independently are fine: their chains are +so, then asks before replacing the SDK (or replaces it with `--force`) and records a fresh snapshot. Pushing the +ref by hand, `git push refs/apimatic/pristine/:refs/apimatic/pristine/`, shares it after the +fact. Two machines that regenerate independently are fine: their chains are joined on the next push, nothing is ever force-pushed. `--zip` is unchanged: no repository, no record, no merge. diff --git a/src/actions/sdk/prepare.ts b/src/actions/sdk/prepare.ts index fea441548..c1ffb07c4 100644 --- a/src/actions/sdk/prepare.ts +++ b/src/actions/sdk/prepare.ts @@ -54,7 +54,7 @@ export class SdkPrepareAction { const state = await repository.inspect(generation, remote); if (state.isErr()) { if (state.error.kind === 'nestedSdkDirectory') { - this.prompts.nestedSdkDirectory(state.error.reason); + this.prompts.nestedSdkDirectory(sdkContext.directory(), state.error); } else { this.prompts.directoryNotInspected(state.error.reason); } @@ -67,23 +67,12 @@ export class SdkPrepareAction { this.prompts.recordNotForThisGeneration(state.value.recorded, generation, sdkContext.directory()); return ActionResult.success({ kind: 'record', repository, remote }); case 'snapshotMissing': - this.prompts.snapshotMissing( - state.value.commit, - repository.root(), - repository.pristineRef(), - state.value.fetch - ); + this.prompts.snapshotMissing(state.value.fetch); return ActionResult.success({ kind: 'record', repository, remote }); case 'unresolved': - if (state.value.fetchedFrom !== undefined) { - this.prompts.snapshotsFetched(state.value.fetchedFrom); - } this.prompts.conflictsUnresolved(state.value.paths, repository.sdkDirectory()); return ActionResult.failed(); case 'ready': - if (state.value.fetchedFrom !== undefined) { - this.prompts.snapshotsFetched(state.value.fetchedFrom); - } return ActionResult.success({ kind: 'merge', repository, base: state.value.base, remote }); } } diff --git a/src/actions/sdk/write.ts b/src/actions/sdk/write.ts index 59623fde7..9337e556d 100644 --- a/src/actions/sdk/write.ts +++ b/src/actions/sdk/write.ts @@ -106,7 +106,7 @@ export class SdkWriteAction { if (staged.isErr()) { this.prompts.conflictsNotStaged(staged.error.reason); } - this.prompts.conflicts(plan, repository.sdkDirectory(), repository.previousRef()); + this.prompts.conflicts(plan, repository.sdkDirectory()); return ActionResult.failed(MERGE_NEEDS_RESOLVING, plan); } @@ -136,9 +136,9 @@ export class SdkWriteAction { } const shared = await repository.shareSnapshot(remote); if (shared.isErr()) { - this.prompts.snapshotNotShared(remote, shared.error.reason, repository.pristineRef()); + this.prompts.snapshotNotShared(remote, shared.error.reason); } else if (shared.value !== 'upToDate') { - this.prompts.snapshotShared(remote, shared.value === 'joined'); + this.prompts.snapshotShared(remote); } } } diff --git a/src/infrastructure/git-service.ts b/src/infrastructure/git-service.ts index 0e23cf5f5..293942f56 100644 --- a/src/infrastructure/git-service.ts +++ b/src/infrastructure/git-service.ts @@ -312,7 +312,7 @@ export class GitService { if (/couldn't find remote ref|no such ref/i.test(result.stderr)) { return undefined; } - throw new GitError(`git fetch failed: ${result.stderr.trim() || `exit code ${result.exitCode}`}`); + throw new GitError(remoteSaid(result.stderr) || `exit code ${result.exitCode}`); } const { stdout } = await this.run(root, ['rev-parse', '--verify', '--quiet', 'FETCH_HEAD']); return CommitId.fromGit(stdout.trim()); @@ -323,7 +323,7 @@ export class GitService { const result = await this.runRemote(root, ['push', '--porcelain', remote, ref.refspec()]); const status = result.stdout.split('\n').find((line) => line.includes(ref.refspec())); if (status === undefined) { - throw new GitError(`git push failed: ${result.stderr.trim() || `exit code ${result.exitCode}`}`); + throw new GitError(remoteSaid(result.stderr) || `exit code ${result.exitCode}`); } if (status.startsWith('=')) { return 'upToDate'; @@ -333,9 +333,7 @@ export class GitService { return 'rejected'; } const summary = status.split(' ').pop()?.trim() ?? ''; - throw new GitError( - `git push failed: ${[summary, remoteSaid(result.stderr)].filter((part) => part !== '').join('; ')}` - ); + throw new GitError(remoteSaid(result.stderr) || summary || `exit code ${result.exitCode}`); } return 'pushed'; } @@ -414,26 +412,29 @@ export class GitService { ['gc.auto', '0'], ...(attributesIn === undefined ? [] : [['core.attributesFile', await this.attributesFile(attributesIn)]]) ].flatMap(([key, value]) => ['-c', `${key}=${value}`]); + let result; try { - const result = await execa('git', [...settings, ...args], { + result = await execa('git', [...settings, ...args], { cwd: cwd.toString(), env: { ...env, GIT_TERMINAL_PROMPT: '0', LC_ALL: 'C' }, input, encoding: encoding as 'utf8', reject: false }); - if (!acceptedExitCodes.includes(result.exitCode ?? -1)) { - throw new GitError(`git ${args[0]} failed: ${String(result.stderr).trim() || `exit code ${result.exitCode}`}`); - } - return result as unknown as { stdout: Buffer & string; stderr: string; exitCode: number }; } catch (error) { - if (error instanceof GitError) { - throw error; - } throw new GitError( error instanceof ExecaError ? `git ${args[0]} failed: ${error.shortMessage}` : errorMessage(error) ); } + if (!acceptedExitCodes.includes(result.exitCode ?? -1)) { + // With no exit code git never ran (or was killed), so execa's own message says why. + const reason = + result.exitCode === undefined + ? result.shortMessage + : String(result.stderr).trim() || `exit code ${result.exitCode}`; + throw new GitError(`git ${args[0]} failed: ${reason}`); + } + return result as unknown as { stdout: Buffer & string; stderr: string; exitCode: number }; } // Trees are stored with line endings normalised, so a CRLF file and its LF twin compare equal diff --git a/src/prompts/sdk/prepare.ts b/src/prompts/sdk/prepare.ts index 8442646da..70b60fcee 100644 --- a/src/prompts/sdk/prepare.ts +++ b/src/prompts/sdk/prepare.ts @@ -1,11 +1,9 @@ import { isCancel, confirm, log } from '@clack/prompts'; import { DirectoryPath } from '../../types/file/directoryPath.js'; import { RelativeFilePath } from '../../types/file/relativeFilePath.js'; -import { CommitId } from '../../types/git/commit-id.js'; import { GitVersion } from '../../types/git/git-version.js'; -import { describeGeneration, SdkGeneration } from '../../types/sdk/generation-record.js'; -import { SnapshotFetch } from '../../types/sdk-repository-context.js'; -import { SnapshotRef } from '../../types/sdk/snapshot-ref.js'; +import { describeApart, SdkGeneration } from '../../types/sdk/generation-record.js'; +import { NestedSdkDirectory, SnapshotFetch } from '../../types/sdk-repository-context.js'; import { format as f } from '../format.js'; const plural = (count: number, noun: string) => `${count} ${noun}${count === 1 ? '' : 's'}`; @@ -37,8 +35,11 @@ export class SdkPreparePrompts { log.warn(`${reason}, so your customizations to the generated SDK will not persist across regenerations.`); } - public nestedSdkDirectory(reason: string) { - log.error(`The SDK cannot be generated here: ${reason}.`); + public nestedSdkDirectory(sdk: DirectoryPath, nested: NestedSdkDirectory) { + const where = nested.encloses + ? `it sits inside the SDK at ${f.relativePath(nested.other)}` + : `the SDK at ${f.relativePath(nested.other)} sits inside it`; + log.error(`The SDK cannot be generated at ${f.relativePath(sdk)}: ${where}.`); } public directoryNotInspected(reason: string) { @@ -46,25 +47,17 @@ export class SdkPreparePrompts { } public recordNotForThisGeneration(recorded: SdkGeneration, requested: SdkGeneration, sdk: DirectoryPath) { - const message = - `${f.relativePath(sdk)} was last generated as ${describeGeneration(recorded)}; ` + - `a ${describeGeneration(requested)} SDK cannot be merged into it and will replace it.`; - log.warn(message); + const [was, now] = describeApart(recorded, requested); + log.warn(`${f.relativePath(sdk)} holds a ${was} SDK; a ${now} SDK cannot be merged into it.`); } - public snapshotMissing(commit: CommitId, root: DirectoryPath, ref: SnapshotRef, fetch: SnapshotFetch) { + public snapshotMissing(fetch: SnapshotFetch) { const message = - `The snapshot this SDK was generated from, commit ${f.var(commit.toString())}, is not in the repository at ` + - `${f.relativePath(root)}, so your customizations cannot be merged. ${whereItWent(fetch)}\n` + - `${howToShare(fetch, ref)}\n` + - `Otherwise the SDK will be replaced and a fresh snapshot recorded.`; + `The snapshot this SDK was generated from is not available ${whereItWasLookedFor(fetch)}, ` + + `so the new SDK cannot be merged into it.`; log.warn(message); } - public snapshotsFetched(remote: string) { - log.info(`Fetched the SDK's snapshots from ${f.var(remote)}.`); - } - public conflictsUnresolved(paths: readonly RelativeFilePath[], sdk: DirectoryPath) { const one = paths.length === 1; const message = @@ -75,26 +68,13 @@ export class SdkPreparePrompts { } } -function whereItWent(fetch: SnapshotFetch): string { +function whereItWasLookedFor(fetch: SnapshotFetch): string { switch (fetch.kind) { case 'noRemote': - return 'The repository has no remote to fetch it from.'; + return 'here, and there is no remote to fetch it from'; case 'absent': - return `${f.var(fetch.remote)} does not have it either.`; + return `here or on ${f.var(fetch.remote)}`; case 'failed': - return `It could not be fetched from ${f.var(fetch.remote)}: ${fetch.reason}`; + return `here, and could not be fetched from ${f.var(fetch.remote)} (${fetch.reason})`; } } - -function howToShare(fetch: SnapshotFetch, ref: SnapshotRef): string { - const remote = fetch.kind === 'noRemote' ? '' : fetch.remote; - return ( - `Whoever generated it shares it by regenerating with a remote, or with ${f.cmd( - 'git', - 'push', - remote, - ref.refspec() - )}; ` + - `then rerun here${fetch.kind === 'noRemote' ? ` after ${f.cmd('git', 'fetch', remote, ref.forcedRefspec())}` : ''}.` - ); -} diff --git a/src/prompts/sdk/write.ts b/src/prompts/sdk/write.ts index 5299bfee4..c6857d759 100644 --- a/src/prompts/sdk/write.ts +++ b/src/prompts/sdk/write.ts @@ -3,7 +3,6 @@ import { DirectoryPath } from '../../types/file/directoryPath.js'; import { RelativeFilePath } from '../../types/file/relativeFilePath.js'; import { SdkSaveProblem } from '../../types/sdk-context.js'; import { ConflictKind, GENERATOR_FILES, MergePlan, YOUR_FILES } from '../../types/sdk/merge-plan.js'; -import { SnapshotRef } from '../../types/sdk/snapshot-ref.js'; import { listedInProse } from '../../utils/string-utils.js'; import { format as f } from '../format.js'; import { noteWrapped } from '../prompt.js'; @@ -65,18 +64,14 @@ export class SdkWritePrompts { log.warn(message); } - public snapshotShared(remote: string, joined: boolean) { - const message = joined - ? `Pushed the SDK's snapshot to ${f.var(remote)}, joined with the one another machine pushed meanwhile.` - : `Pushed the SDK's snapshot to ${f.var(remote)}, so a clone can merge too.`; - log.info(message); + public snapshotShared(remote: string) { + log.info(`Pushed the SDK's snapshot to ${f.var(remote)}.`); } - public snapshotNotShared(remote: string, reason: string, ref: SnapshotRef) { + public snapshotNotShared(remote: string, reason: string) { const message = - `The SDK's snapshot could not be pushed to ${f.var(remote)}: ${reason}\n` + - `A clone will replace the SDK instead of merging until it is shared; ` + - `${f.cmd('git', 'push', remote, ref.refspec())} does it by hand.`; + `Could not push the SDK's snapshot to ${f.var(remote)}: ${reason}\n` + + `Clones will replace the SDK instead of merging.`; log.warn(message); } @@ -97,7 +92,7 @@ export class SdkWritePrompts { log.info(lines.join('\n')); } - public conflicts(plan: MergePlan, sdk: DirectoryPath, previous: SnapshotRef) { + public conflicts(plan: MergePlan, sdk: DirectoryPath) { const { conflicts } = plan; const lines = conflicts.map( (conflict) => `${f.relativePath(conflict.path.in(sdk))}\n ${conflict.message ?? CONFLICT_LABELS[conflict.kind]}` @@ -105,12 +100,7 @@ export class SdkWritePrompts { const marked = plan.hasMarkedConflicts(); const unmarked = plan.hasUnmarkedConflicts(); const steps = [ - ...(marked - ? [ - `Open each marked file, keep what is right, and remove the markers; ${f.cmd('git', 'status')} lists ` + - `them as both modified and ${f.cmd('git', 'mergetool')} opens them.` - ] - : []), + ...(marked ? ['Open each marked file, keep what is right, and remove the markers.'] : []), ...(unmarked ? [ `For each ${ @@ -118,8 +108,7 @@ export class SdkWritePrompts { }file, decide whether it stays or goes: delete it again, or keep it as your own.` ] : []), - 'Rerun the command; nothing needs committing first.', - `The directory as it was before this run is under ${f.var(previous.toString())}.` + 'Rerun the command; nothing needs committing first.' ]; noteWrapped( [...lines, '', ...steps.map((step, index) => `${index + 1}. ${step}`)].join('\n'), diff --git a/src/types/sdk-repository-context.ts b/src/types/sdk-repository-context.ts index 3930df85b..b755b44b0 100644 --- a/src/types/sdk-repository-context.ts +++ b/src/types/sdk-repository-context.ts @@ -16,10 +16,13 @@ import { describeGeneration, GenerationRecord, SdkGeneration } from './sdk/gener import { MergePlan } from './sdk/merge-plan.js'; import { SnapshotRef } from './sdk/snapshot-ref.js'; -export type SdkRepositoryProblem = { - kind: 'nestedSdkDirectory' | 'gitFailed' | 'recordNotWritten'; - reason: string; -}; +export type SdkRepositoryProblem = { kind: 'gitFailed' | 'recordNotWritten'; reason: string }; + +/** + * Two SDK directories, one inside the other: refs are files, so only one of them can have + * snapshots. `encloses` says the other one is the outer directory. + */ +export type NestedSdkDirectory = { kind: 'nestedSdkDirectory'; other: DirectoryPath; encloses: boolean }; /** Why a recorded snapshot is still absent after the one place it could have come from was tried. */ export type SnapshotFetch = @@ -31,12 +34,12 @@ export type SnapshotFetch = export type SdkRepositoryState = | { kind: 'noRecord' } | { kind: 'otherGeneration'; recorded: SdkGeneration } - | { kind: 'snapshotMissing'; commit: CommitId; fetch: SnapshotFetch } - | { kind: 'unresolved'; paths: RelativeFilePath[]; fetchedFrom?: string } - | { kind: 'ready'; base: CommitId; fetchedFrom?: string }; + | { kind: 'snapshotMissing'; fetch: SnapshotFetch } + | { kind: 'unresolved'; paths: RelativeFilePath[] } + | { kind: 'ready'; base: CommitId }; /** What sharing a snapshot did at the remote. */ -export type SnapshotShare = 'pushed' | 'upToDate' | 'joined'; +export type SnapshotShare = 'pushed' | 'upToDate'; /** The plan, the merged tree, and git's versions of every conflicted path, below the SDK directory. */ export type MergeOutcome = { plan: MergePlan; merged: TreeId; stages: StageEntry[] }; @@ -99,7 +102,7 @@ export class SdkRepositoryContext { public async inspect( generation: SdkGeneration, remote: string | undefined - ): Promise> { + ): Promise> { const notNested = await this.verifyNotNested(); if (notNested.isErr()) { return err(notNested.error); @@ -112,26 +115,20 @@ export class SdkRepositoryContext { return ok({ kind: 'otherGeneration', recorded: record.generatedAs() }); } const base = record.pristineCommit(); - let fetchedFrom: string | undefined; if (!(await this.git.hasCommit(this.rootDirectory, base))) { if (remote === undefined) { - return ok({ kind: 'snapshotMissing', commit: base, fetch: { kind: 'noRemote' } }); + return ok({ kind: 'snapshotMissing', fetch: { kind: 'noRemote' } }); } const fetched = await this.fetchSnapshots(remote); if (fetched.isErr()) { - return ok({ - kind: 'snapshotMissing', - commit: base, - fetch: { kind: 'failed', remote, reason: fetched.error.reason } - }); + return ok({ kind: 'snapshotMissing', fetch: { kind: 'failed', remote, reason: fetched.error.reason } }); } if (!(await this.git.hasCommit(this.rootDirectory, base))) { - return ok({ kind: 'snapshotMissing', commit: base, fetch: { kind: 'absent', remote } }); + return ok({ kind: 'snapshotMissing', fetch: { kind: 'absent', remote } }); } - fetchedFrom = remote; } const paths = await this.unresolvedConflicts(record); - return ok(paths.length > 0 ? { kind: 'unresolved', paths, fetchedFrom } : { kind: 'ready', base, fetchedFrom }); + return ok(paths.length > 0 ? { kind: 'unresolved', paths } : { kind: 'ready', base }); } /** @@ -153,7 +150,7 @@ export class SdkRepositoryContext { if (second === 'rejected') { return err(gitFailed(`${remote} refused the snapshot twice; try again`)); } - return ok(second === 'pushed' ? 'joined' : second); + return ok(second); } catch (error) { return err(gitFailed(errorMessage(error))); } @@ -187,10 +184,6 @@ export class SdkRepositoryContext { generated: TreeId, generation: SdkGeneration ): Promise> { - const notNested = await this.verifyNotNested(); - if (notNested.isErr()) { - return err(notNested.error); - } const ref = this.pristineRef(); try { const parent = await this.git.resolveRef(this.rootDirectory, ref); @@ -339,16 +332,15 @@ export class SdkRepositoryContext { } // The ref name is the SDK directory's path, so one SDK directory inside another cannot have its own. - private async verifyNotNested(): Promise> { + private async verifyNotNested(): Promise> { const ref = this.pristineRef(); try { const colliding = (await this.pristineRefsIn()).find((existing) => existing.collidesWith(ref)); - return colliding === undefined - ? ok(undefined) - : err({ - kind: 'nestedSdkDirectory', - reason: `${ref} cannot be kept beside ${colliding}: one SDK directory sits inside another` - }); + if (colliding === undefined) { + return ok(undefined); + } + const other = this.rootDirectory.join(colliding.sdkPath()); + return err({ kind: 'nestedSdkDirectory', other, encloses: other.contains(this.sdk) }); } catch (error) { return err(gitFailed(errorMessage(error))); } diff --git a/src/types/sdk/generation-record.ts b/src/types/sdk/generation-record.ts index 8a453fef1..caf233f2f 100644 --- a/src/types/sdk/generation-record.ts +++ b/src/types/sdk/generation-record.ts @@ -140,3 +140,10 @@ export class GenerationRecord { export function describeGeneration(generation: SdkGeneration): string { return `${generation.language} ${generation.codegenVersion} ${generation.stability}`; } + +/** Each as "stable typescript", with the codegen version added only when that is what sets them apart. */ +export function describeApart(recorded: SdkGeneration, requested: SdkGeneration): [string, string] { + const brief = (generation: SdkGeneration) => `${generation.stability} ${generation.language}`; + const full = (generation: SdkGeneration) => `${brief(generation)} (codegen ${generation.codegenVersion})`; + return brief(recorded) === brief(requested) ? [full(recorded), full(requested)] : [brief(recorded), brief(requested)]; +} diff --git a/src/types/sdk/snapshot-ref.ts b/src/types/sdk/snapshot-ref.ts index 68f82c62f..d9dc7449c 100644 --- a/src/types/sdk/snapshot-ref.ts +++ b/src/types/sdk/snapshot-ref.ts @@ -26,6 +26,12 @@ export class SnapshotRef { return PRISTINE; } + /** The SDK directory's path below the repository root, which the ref is named after; '' for the root itself. */ + public sdkPath(): string { + const namespace = this.name.startsWith(PREVIOUS) ? PREVIOUS : PRISTINE; + return this.name.slice(namespace.length + 1); + } + /** Git stores refs as files in directories, so a ref cannot sit on another ref's path. */ public collidesWith(other: SnapshotRef): boolean { return ( diff --git a/test/actions/sdk/prepare.test.ts b/test/actions/sdk/prepare.test.ts index 817bbc850..cee028c93 100644 --- a/test/actions/sdk/prepare.test.ts +++ b/test/actions/sdk/prepare.test.ts @@ -112,14 +112,14 @@ describe('SdkPrepareAction', () => { expect(notForThis.firstCall.args[1]).to.deep.equal(BETA); }); - it('records over an SDK whose snapshot the repository does not hold, naming the commit', async () => { + it('records over an SDK whose snapshot the repository does not hold, after saying so', async () => { await recorded({ 'README.md': '# sdk' }); const missing = CommitId.create('f'.repeat(40))!; write('.apimatic/generation.json', GenerationRecord.written(STABLE, missing, new Date(), '0.0.0').toJson()); const snapshotMissing = sinon.stub(SdkPreparePrompts.prototype, 'snapshotMissing'); expect((await execute()).getValue().kind).to.equal('record'); - expect(snapshotMissing.firstCall.args[0].isEqual(missing)).to.be.true; + expect(snapshotMissing.firstCall.args[0]).to.deep.equal({ kind: 'noRemote' }); }); it('merges in a clone only once the pristine refs were fetched by hand, and says how', async () => { @@ -140,7 +140,7 @@ describe('SdkPrepareAction', () => { const snapshotMissing = sinon.stub(SdkPreparePrompts.prototype, 'snapshotMissing'); expect((await execute()).getValue().kind).to.equal('record'); - expect(snapshotMissing.firstCall.args[0].isEqual(base)).to.be.true; + expect(snapshotMissing.firstCall.args[0]).to.deep.equal({ kind: 'absent', remote: 'origin' }); await sh(root, 'push', '-q', 'origin', 'refs/apimatic/pristine/*:refs/apimatic/pristine/*'); await sh(clone, 'fetch', '-q', 'origin', '+refs/apimatic/pristine/*:refs/apimatic/pristine/*'); @@ -183,7 +183,9 @@ describe('SdkPrepareAction', () => { const nestedSdkDirectory = sinon.stub(SdkPreparePrompts.prototype, 'nestedSdkDirectory'); expect((await execute()).isFailed()).to.be.true; - expect(nestedSdkDirectory.firstCall.args[0]).to.contain('refs/apimatic/pristine/sdk/python/python'); + expect(nestedSdkDirectory.firstCall.args[0].isEqual(sdk().join('python'))).to.be.true; + expect(nestedSdkDirectory.firstCall.args[1].other.isEqual(sdk())).to.be.true; + expect(nestedSdkDirectory.firstCall.args[1].encloses).to.be.true; }); it('reports a directory git could not inspect', async () => { @@ -212,6 +214,15 @@ describe('SdkPrepareAction', () => { expect((await execute({ force: false })).getValue().kind).to.equal('record'); }); + it('follows the warning about another generation, and no cancels there too', async () => { + await recorded({ 'README.md': '# sdk' }); + const notForThis = sinon.stub(SdkPreparePrompts.prototype, 'recordNotForThisGeneration'); + const overwriteSdk = sinon.stub(SdkPreparePrompts.prototype, 'overwriteSdk').resolves(false); + + expect((await execute({ generation: BETA, force: false })).isCancelled()).to.be.true; + expect(notForThis.calledBefore(overwriteSdk)).to.be.true; + }); + it('is skipped by --force', async () => { const overwriteSdk = sinon.stub(SdkPreparePrompts.prototype, 'overwriteSdk'); @@ -244,13 +255,10 @@ describe('SdkPrepareAction', () => { it('merges in a clone by itself, fetching the snapshot the origin shared', async () => { const { clone, origin, base } = await shared(); try { - const snapshotsFetched = sinon.stub(SdkPreparePrompts.prototype, 'snapshotsFetched'); - const destination = (await execute()).getValue(); expect(destination.kind === 'merge' && destination.base.isEqual(base)).to.be.true; expect(destination.kind === 'merge' && destination.remote).to.equal('origin'); - expect(snapshotsFetched.firstCall.args[0]).to.equal('origin'); } finally { fs.rmSync(clone, { recursive: true, force: true }); fs.rmSync(origin, { recursive: true, force: true }); diff --git a/test/actions/sdk/write.test.ts b/test/actions/sdk/write.test.ts index c20da357d..9bd917e89 100644 --- a/test/actions/sdk/write.test.ts +++ b/test/actions/sdk/write.test.ts @@ -292,7 +292,7 @@ describe('SdkWriteAction', () => { expect(result.isSuccess()).to.be.true; expect(share.firstCall.args[0]).to.equal('origin'); - expect(snapshotShared.firstCall.args).to.deep.equal(['origin', false]); + expect(snapshotShared.firstCall.args).to.deep.equal(['origin']); }); it('says nothing when the remote already had it', async () => { @@ -320,7 +320,7 @@ describe('SdkWriteAction', () => { ); expect(result.isSuccess()).to.be.true; - expect(snapshotNotShared.firstCall.args.slice(0, 2)).to.deep.equal(['origin', 'no access']); + expect(snapshotNotShared.firstCall.args).to.deep.equal(['origin', 'no access']); }); it('leaves the remote alone when the destination names none', async () => { diff --git a/test/types/sdk-repository-context.test.ts b/test/types/sdk-repository-context.test.ts index bffb7a0f7..5b9514022 100644 --- a/test/types/sdk-repository-context.test.ts +++ b/test/types/sdk-repository-context.test.ts @@ -171,14 +171,22 @@ describe('SdkRepositoryContext', () => { fs.mkdirSync(outer.sdkDirectory().join('typescript').toString()); const inner = (await contextFor(outer.sdkDirectory().join('typescript')).repository())!; - expect((await record(inner, { 'README.md': '# inner' }))._unsafeUnwrapErr().kind).to.equal('nestedSdkDirectory'); + expect((await inner.inspect(TYPESCRIPT, undefined))._unsafeUnwrapErr()).to.deep.include({ + kind: 'nestedSdkDirectory', + other: outer.sdkDirectory(), + encloses: true + }); const other = await project(path.join('other', 'typescript', 'nested')); (await record(other, { 'README.md': '# nested' }))._unsafeUnwrap(); const parent = (await contextFor( new DirectoryPath(path.join(root, 'project', 'other', 'typescript')) ).repository())!; - expect((await record(parent, {}))._unsafeUnwrapErr().kind).to.equal('nestedSdkDirectory'); + expect((await parent.inspect(TYPESCRIPT, undefined))._unsafeUnwrapErr()).to.deep.include({ + kind: 'nestedSdkDirectory', + other: other.sdkDirectory(), + encloses: false + }); }); it('refuses any SDK directory below a repository whose root is one', async () => { @@ -190,7 +198,11 @@ describe('SdkRepositoryContext', () => { fs.mkdirSync(sdk.join('typescript').toString()); const below = (await contextFor(sdk.join('typescript')).repository())!; - expect((await record(below, {}))._unsafeUnwrapErr().kind).to.equal('nestedSdkDirectory'); + expect((await below.inspect(TYPESCRIPT, undefined))._unsafeUnwrapErr()).to.deep.include({ + kind: 'nestedSdkDirectory', + other: sdk, + encloses: true + }); }); }); @@ -290,7 +302,6 @@ describe('SdkRepositoryContext', () => { const state = (await repository.inspect(TYPESCRIPT, undefined))._unsafeUnwrap(); expect(state.kind).to.equal('snapshotMissing'); - expect(state.kind === 'snapshotMissing' && state.commit.isEqual(missing)).to.be.true; }); it('names the listed files that still hold conflict markers, and not the resolved or removed ones', async () => { @@ -388,7 +399,7 @@ describe('SdkRepositoryContext', () => { (await repository.shareSnapshot('origin'))._unsafeUnwrap(); const after = (await clone.inspect(TYPESCRIPT, 'origin'))._unsafeUnwrap(); - expect(after.kind === 'ready' && after.base.isEqual(base) && after.fetchedFrom).to.equal('origin'); + expect(after.kind === 'ready' && after.base.isEqual(base)).to.be.true; expect((await git.resolveRef(clone.root(), clone.pristineRef()))?.isEqual(base)).to.be.true; }); @@ -419,7 +430,7 @@ describe('SdkRepositoryContext', () => { const b = (await contextFor(bRoot.join('sdk', 'typescript')).repository())!; const second = (await record(b, { 'README.md': '# b' }))._unsafeUnwrap(); - expect((await b.shareSnapshot('origin'))._unsafeUnwrap()).to.equal('joined'); + expect((await b.shareSnapshot('origin'))._unsafeUnwrap()).to.equal('pushed'); const tip = (await git.resolveRef(origin, b.pristineRef()))!; expect((await git.describe(origin, tip)).parents.map(String)).to.have.members([ From e1dd8d6cb834f0fd3c57a49c2681d8be3828e005 Mon Sep 17 00:00:00 2001 From: Asad Ali <14asadali@gmail.com> Date: Wed, 21 Oct 2026 17:04:25 +0500 Subject: [PATCH 17/29] feat(sdk)!: remove --zip from sdk generate The flag wrote the archive the server sent and nothing else, which custom code could never apply to, and nobody needs the CLI to zip a directory. SdkContext.save only copies now and cannot fail with a result, so the "could not be saved" prompt and SdkSaveProblem go with it; the destination's overwrite kind has no zip field. The server still delivers a zip, which the CLI unpacks into a temp directory, and portal generate --zip is untouched. SdkWriteAction's three destinations are written out in its switch instead of three private methods. BREAKING CHANGE: `apimatic sdk generate --zip` is no longer accepted. Generate into a directory and archive it yourself. Co-Authored-By: Claude Fable 5.1 --- .ai/plans/custom-code.md | 22 ++--- README.md | 7 +- src/actions/sdk/generate.ts | 3 +- src/actions/sdk/prepare.ts | 16 +--- src/actions/sdk/publish.ts | 1 - src/actions/sdk/write.ts | 138 +++++++++++------------------ src/commands/sdk/generate.ts | 10 +-- src/prompts/sdk/write.ts | 5 -- src/types/sdk-context.ts | 20 +---- src/types/sdk/sdk-destination.ts | 2 +- test/actions/sdk/generate.test.ts | 26 +----- test/actions/sdk/prepare.test.ts | 18 +--- test/actions/sdk/write.test.ts | 21 +---- test/types/project-context.test.ts | 4 +- test/types/sdk-context.test.ts | 24 +---- 15 files changed, 88 insertions(+), 229 deletions(-) diff --git a/.ai/plans/custom-code.md b/.ai/plans/custom-code.md index 7d709bd19..5a8a6a89a 100644 --- a/.ai/plans/custom-code.md +++ b/.ai/plans/custom-code.md @@ -50,7 +50,7 @@ CLI. |---|---|---| | Where the merge runs | In the CLI, against the working tree. The server is unchanged. | Nothing is uploaded or downloaded beyond the SDK zip, the merge sees uncommitted edits, and the design matches Speakeasy's, which the lab showed working. | | When it merges | Always, whenever git is available, the SDK directory sits in a git repository and has a generation record. No switch in `apimatic.json`. | One rule to explain: "with git installed, your edits are kept". A switch would be read only after the first edit is already lost. | -| Git is a hard requirement | The system git, 2.38 or newer, found on `PATH` at the start of every non-`--zip` run. Absent or older: today's flow, no `.git` or `.apimatic/` written, one warning. No bundled fallback engine. | Git's merge is the reference everyone else measures against: rename detection, modify/delete and add/add semantics, binary handling. Generators that embed a library write their own diff3 (Speakeasy, isomorphic-git); tools that accept git as a prerequisite shell out to it (JHipster, Copier, Cruft); none reimplements rename detection. 2.38 (October 2022) is the first with `merge-tree --write-tree`. | +| Git is a hard requirement | The system git, 2.38 or newer, found on `PATH` at the start of every run. Absent or older: today's flow, no `.git` or `.apimatic/` written, one warning. No bundled fallback engine. | Git's merge is the reference everyone else measures against: rename detection, modify/delete and add/add semantics, binary handling. Generators that embed a library write their own diff3 (Speakeasy, isomorphic-git); tools that accept git as a prerequisite shell out to it (JHipster, Copier, Cruft); none reimplements rename detection. 2.38 (October 2022) is the first with `merge-tree --write-tree`. | | No repository | After the generation succeeds and before the first write, `git init -b main` in the SDK directory, reported afterwards. A non-empty directory without a repository is asked "overwrite?" first. | Without a snapshot of the first generation there is nothing to merge against later. The init is inside the developer's own destination directory. | | Repository found but no record | Ask "overwrite?" as before (skipped by `--force`); then record a snapshot. | An SDK generated by an older CLI, copied in, or built from the portal has no base. | | Record for another generation | A record for another stability (or generator) is "no base": a warning, the overwrite question, a fresh snapshot. | Merging a beta generation over a stable base would be a merge of everything; replacing is what the developer asked for. | @@ -62,7 +62,6 @@ CLI. | Generator drops an edited file | A conflict: the file stays, listed as "no longer generated". | Lab case 7: Speakeasy deletes it with no warning. | | Developer deleted a file the generator changed | A conflict: the new version is written back and listed. Needs sign-off, section 10. | Git semantics. The alternative, remembering the deletion, is silent and recurs on docs files. | | `--force` | Keeps its meaning: never ask. With a base it changes nothing, since nothing is asked. | CI runs already pass it. | -| `--zip` | Unchanged: no repository, no record, no merge. | A zip is not a working tree. | | Undo | The SDK directory as it was before the run is kept under `refs/apimatic/previous/…`, overwritten each run. | A merge that goes wrong on uncommitted edits would otherwise be unrecoverable. | | Conflicts in git's index | The conflicted paths are put into the repository's index at stages 1, 2 and 3. | `git status` says "both modified" and `git mergetool` opens them, as after a merge git ran itself. | @@ -145,9 +144,7 @@ before the fresh SDK was copied in. Nothing on disk survived a regeneration. ```mermaid flowchart TD - A([apimatic sdk generate -l typescript]) --> B{--zip?} - B -- yes --> Z([Today's flow: download the zip; done]) - B -- no --> V{git 2.38+ on PATH?} + A([apimatic sdk generate -l typescript]) --> V{git 2.38+ on PATH?} V -- no --> V1([Warn: customizations will not persist; today's flow, no .git, no .apimatic]) V -- yes --> D{Generation record and its pristine commit found?} D -- no, directory empty --> C1 @@ -171,21 +168,20 @@ flowchart TD The run is three actions. `GenerateAction` validates as before and generates into a temp directory. `SdkPrepareAction` runs before the upload and returns an `SdkDestination`: -1. `--zip` takes today's path entirely: `overwrite`. -2. `git --version`. Absent, or older than 2.38: warn that customizations will not persist; +1. `git --version`. Absent, or older than 2.38: warn that customizations will not persist; `overwrite`, writing no `.git` and no `.apimatic/`. -3. `SdkContext.repository()` finds the enclosing repository (from the +2. `SdkContext.repository()` finds the enclosing repository (from the nearest existing ancestor, since a first run's directory is not there yet), the nesting check, the record, whether its commit is present, and which listed files still hold markers. A nested SDK directory is refused here. -4. No repository, no record, a record for another generation, or a missing commit: `record`, +3. No repository, no record, a record for another generation, or a missing commit: `record`, each with its message. Markers left: refused. Otherwise `merge` against the recorded base. -5. The overwrite question is asked for `overwrite` and `record` when the directory holds +4. The overwrite question is asked for `overwrite` and `record` when the directory holds files, never for `merge`, and never with `--force`. After the download, `SdkWriteAction` writes it the way the destination says: -- `overwrite`: today's save, as a directory or a zip; a `.git` in the directory stays. +- `overwrite`: today's save into the directory; a `.git` in it stays. - `record`: `git init -b main` in the SDK directory when nothing encloses it (after the generation succeeded, so a failed one leaves no repository behind), the save, then the pristine commit and the record. A failure to record is a warning: the SDK is on disk, and @@ -346,6 +342,10 @@ PR into `dev`, squash-merged, title `feat(sdk): keep your edits when regeneratin policy against tool pushes, a dead network) are per-repository, not per-run, and every one of them is already a warning that does not fail the run. If an opt-out is ever needed, a git config read by `remoteOf` fits better than a flag. +- **No `--zip` flag.** Decided 2026-10-21: `sdk generate --zip` wrote the archive the server + sent and nothing else, which custom code could never apply to, and nobody needs the CLI to + zip a directory. Removed outright rather than hidden and deprecated. The server still delivers + a zip, which the CLI unpacks into a temp directory; `portal generate --zip` is untouched. ## 11. Relation to other work diff --git a/README.md b/README.md index 42ac7470f..b95da8f93 100644 --- a/README.md +++ b/README.md @@ -56,8 +56,6 @@ ref by hand, `git push refs/apimatic/pristine/:refs/apimatic/pris fact. Two machines that regenerate independently are fine: their chains are joined on the next push, nothing is ever force-pushed. -`--zip` is unchanged: no repository, no record, no merge. - # Usage ```sh-session @@ -449,7 +447,7 @@ Generate an SDK for your API ``` USAGE $ apimatic sdk generate -l csharp|java|php|python|ruby|typescript|go [-d ] [--api-version ] - [--zip] [--codegen-version v4] [--stability stable|beta] [-i ] [-f] [-k ] + [--codegen-version v4] [--stability stable|beta] [-i ] [-f] [-k ] FLAGS -d, --destination= [default: /sdk/ | /sdk//] path where @@ -465,7 +463,6 @@ FLAGS --stability=