diff --git a/.gitignore b/.gitignore index 381c58f..520b71b 100644 --- a/.gitignore +++ b/.gitignore @@ -1,7 +1,7 @@ -.idea/ -**/node_modules/ -**/build/ -.vscode/ -coverage/ -logs/ +.idea/** +**/node_modules/** +**/build/** +.vscode/** +coverage/** +logs/** **/*.env \ No newline at end of file diff --git a/README.md b/README.md index 059238b..bb78bb2 100644 --- a/README.md +++ b/README.md @@ -44,6 +44,7 @@ Checkout my [Notion](https://mohitjain.notion.site/Coding-Challenges-af9b8197a43 23. [Write Your Own Traceroute](src/23/) 24. [Write Your Own Realtime Chat Client and Server - Duplicate of Write Your Own IRC Client](src/16/) 25. [Write Your Own NATS Message Broker](src/25/) +26. [Write Your Own Git](src/26/) ## Installation diff --git a/package-lock.json b/package-lock.json index ddf2f6a..f99dc9b 100644 --- a/package-lock.json +++ b/package-lock.json @@ -11,11 +11,15 @@ "dependencies": { "@datastructures-js/priority-queue": "^6.3.0", "axios": "^1.4.0", + "commander": "^11.0.0", "cors": "^2.8.5", + "diff": "^5.1.0", "discord.js": "^14.13.0", "dotenv": "^16.3.1", "express": "^4.18.2", + "glob": "^10.3.9", "helmet": "^7.0.0", + "ini": "^4.1.1", "jsdom": "^22.1.0", "memcached": "^2.2.2", "raw-socket": "github:algj/node-raw-socket", @@ -28,7 +32,10 @@ "@babel/core": "^7.22.9", "@babel/preset-env": "^7.22.9", "@babel/preset-typescript": "^7.22.5", + "@types/diff": "^5.0.5", "@types/express": "^4.17.17", + "@types/glob": "^8.1.0", + "@types/ini": "^1.3.31", "@types/jest": "^29.5.3", "@types/jsdom": "^21.1.2", "@types/memcached": "^2.2.7", @@ -2208,6 +2215,95 @@ "integrity": "sha512-ZnQMnLV4e7hDlUvw8H+U8ASL02SS2Gn6+9Ac3wGGLIe7+je2AeAOxPY+izIPJDfFDb7eDjev0Us8MO1iFRN8hA==", "dev": true }, + "node_modules/@isaacs/cliui": { + "version": "8.0.2", + "resolved": "https://registry.npmjs.org/@isaacs/cliui/-/cliui-8.0.2.tgz", + "integrity": "sha512-O8jcjabXaleOG9DQ0+ARXWZBTfnP4WNAqzuiJK7ll44AmxGKv/J2M4TPjxjY3znBCfvBXFzucm1twdyFybFqEA==", + "dependencies": { + "string-width": "^5.1.2", + "string-width-cjs": "npm:string-width@^4.2.0", + "strip-ansi": "^7.0.1", + "strip-ansi-cjs": "npm:strip-ansi@^6.0.1", + "wrap-ansi": "^8.1.0", + "wrap-ansi-cjs": "npm:wrap-ansi@^7.0.0" + }, + "engines": { + "node": ">=12" + } + }, + "node_modules/@isaacs/cliui/node_modules/ansi-regex": { + "version": "6.0.1", + "resolved": "https://registry.npmjs.org/ansi-regex/-/ansi-regex-6.0.1.tgz", + "integrity": "sha512-n5M855fKb2SsfMIiFFoVrABHJC8QtHwVx+mHWP3QcEqBHYienj5dHSgjbxtC0WEZXYt4wcD6zrQElDPhFuZgfA==", + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/chalk/ansi-regex?sponsor=1" + } + }, + "node_modules/@isaacs/cliui/node_modules/ansi-styles": { + "version": "6.2.1", + "resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-6.2.1.tgz", + "integrity": "sha512-bN798gFfQX+viw3R7yrGWRqnrN2oRkEkUjjl4JNn4E8GxxbjtG3FbrEIIY3l8/hrwUwIeCZvi4QuOTP4MErVug==", + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/chalk/ansi-styles?sponsor=1" + } + }, + "node_modules/@isaacs/cliui/node_modules/emoji-regex": { + "version": "9.2.2", + "resolved": "https://registry.npmjs.org/emoji-regex/-/emoji-regex-9.2.2.tgz", + "integrity": "sha512-L18DaJsXSUk2+42pv8mLs5jJT2hqFkFE4j21wOmgbUqsZ2hL72NsUU785g9RXgo3s0ZNgVl42TiHp3ZtOv/Vyg==" + }, + "node_modules/@isaacs/cliui/node_modules/string-width": { + "version": "5.1.2", + "resolved": "https://registry.npmjs.org/string-width/-/string-width-5.1.2.tgz", + "integrity": "sha512-HnLOCR3vjcY8beoNLtcjZ5/nxn2afmME6lhrDrebokqMap+XbeW8n9TXpPDOqdGK5qcI3oT0GKTW6wC7EMiVqA==", + "dependencies": { + "eastasianwidth": "^0.2.0", + "emoji-regex": "^9.2.2", + "strip-ansi": "^7.0.1" + }, + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/@isaacs/cliui/node_modules/strip-ansi": { + "version": "7.1.0", + "resolved": "https://registry.npmjs.org/strip-ansi/-/strip-ansi-7.1.0.tgz", + "integrity": "sha512-iq6eVVI64nQQTRYq2KtEg2d2uU7LElhTJwsH4YzIHZshxlgZms/wIc4VoDQTlG/IvVIrBKG06CrZnp0qv7hkcQ==", + "dependencies": { + "ansi-regex": "^6.0.1" + }, + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/chalk/strip-ansi?sponsor=1" + } + }, + "node_modules/@isaacs/cliui/node_modules/wrap-ansi": { + "version": "8.1.0", + "resolved": "https://registry.npmjs.org/wrap-ansi/-/wrap-ansi-8.1.0.tgz", + "integrity": "sha512-si7QWI6zUMq56bESFvagtmzMdGOtoxfR+Sez11Mobfc7tm+VkUckk9bW2UeffTGVUbOksxmSw0AA2gs8g71NCQ==", + "dependencies": { + "ansi-styles": "^6.1.0", + "string-width": "^5.0.1", + "strip-ansi": "^7.0.1" + }, + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/chalk/wrap-ansi?sponsor=1" + } + }, "node_modules/@istanbuljs/load-nyc-config": { "version": "1.1.0", "resolved": "https://registry.npmjs.org/@istanbuljs/load-nyc-config/-/load-nyc-config-1.1.0.tgz", @@ -2520,6 +2616,26 @@ "@jridgewell/sourcemap-codec": "1.4.14" } }, + "node_modules/@jest/reporters/node_modules/glob": { + "version": "7.2.3", + "resolved": "https://registry.npmjs.org/glob/-/glob-7.2.3.tgz", + "integrity": "sha512-nFR0zLpU2YCaRxwoCJvL6UvCH2JFyFVIvwTLsIf21AuHlMskA1hhTdk+LlYJtOlYt9v6dvszD2BGRqBL+iQK9Q==", + "dev": true, + "dependencies": { + "fs.realpath": "^1.0.0", + "inflight": "^1.0.4", + "inherits": "2", + "minimatch": "^3.1.1", + "once": "^1.3.0", + "path-is-absolute": "^1.0.0" + }, + "engines": { + "node": "*" + }, + "funding": { + "url": "https://github.com/sponsors/isaacs" + } + }, "node_modules/@jest/schemas": { "version": "29.6.0", "resolved": "https://registry.npmjs.org/@jest/schemas/-/schemas-29.6.0.tgz", @@ -2758,6 +2874,15 @@ "node": ">= 8" } }, + "node_modules/@pkgjs/parseargs": { + "version": "0.11.0", + "resolved": "https://registry.npmjs.org/@pkgjs/parseargs/-/parseargs-0.11.0.tgz", + "integrity": "sha512-+1VkjdD0QBLPodGrJUeqarH8VAIvQODIbwh9XpP5Syisf7YoQgsJKPNFoqqLQlu+VQ/tVSshMR6loPMn8U+dPg==", + "optional": true, + "engines": { + "node": ">=14" + } + }, "node_modules/@pkgr/utils": { "version": "2.4.2", "resolved": "https://registry.npmjs.org/@pkgr/utils/-/utils-2.4.2.tgz", @@ -2980,6 +3105,12 @@ "integrity": "sha512-t73xJJrvdTjXrn4jLS9VSGRbz0nUY3cl2DMGDU48lKl+HR9dbbjW2A9r3g40VA++mQpy6uuHg33gy7du2BKpog==", "dev": true }, + "node_modules/@types/diff": { + "version": "5.0.5", + "resolved": "https://registry.npmjs.org/@types/diff/-/diff-5.0.5.tgz", + "integrity": "sha512-rt7WqM1bWwKJMRxlB5Rhke56UN21Bqwp1ILER31bafTivcapYdfhtPd5xRWfhf08yjPxoDcfjVkkECdRwFe7EA==", + "dev": true + }, "node_modules/@types/express": { "version": "4.17.17", "resolved": "https://registry.npmjs.org/@types/express/-/express-4.17.17.tgz", @@ -3004,6 +3135,16 @@ "@types/send": "*" } }, + "node_modules/@types/glob": { + "version": "8.1.0", + "resolved": "https://registry.npmjs.org/@types/glob/-/glob-8.1.0.tgz", + "integrity": "sha512-IO+MJPVhoqz+28h1qLAcBEH2+xHMK6MTyHJc7MTnnYb6wsoLR29POVGJ7LycmVXIqyy/4/2ShP5sUwTXuOwb/w==", + "dev": true, + "dependencies": { + "@types/minimatch": "^5.1.2", + "@types/node": "*" + } + }, "node_modules/@types/graceful-fs": { "version": "4.1.6", "resolved": "https://registry.npmjs.org/@types/graceful-fs/-/graceful-fs-4.1.6.tgz", @@ -3019,6 +3160,12 @@ "integrity": "sha512-/K3ds8TRAfBvi5vfjuz8y6+GiAYBZ0x4tXv1Av6CWBWn0IlADc+ZX9pMq7oU0fNQPnBwIZl3rmeLp6SBApbxSQ==", "dev": true }, + "node_modules/@types/ini": { + "version": "1.3.31", + "resolved": "https://registry.npmjs.org/@types/ini/-/ini-1.3.31.tgz", + "integrity": "sha512-8ecxxaG4AlVEM1k9+BsziMw8UsX0qy3jYI1ad/71RrDZ+rdL6aZB0wLfAuflQiDhkD5o4yJ0uPK3OSUic3fG0w==", + "dev": true + }, "node_modules/@types/istanbul-lib-coverage": { "version": "2.0.4", "resolved": "https://registry.npmjs.org/@types/istanbul-lib-coverage/-/istanbul-lib-coverage-2.0.4.tgz", @@ -3085,6 +3232,12 @@ "integrity": "sha512-YATxVxgRqNH6nHEIsvg6k2Boc1JHI9ZbH5iWFFv/MTkchz3b1ieGDa5T0a9RznNdI0KhVbdbWSN+KWWrQZRxTw==", "dev": true }, + "node_modules/@types/minimatch": { + "version": "5.1.2", + "resolved": "https://registry.npmjs.org/@types/minimatch/-/minimatch-5.1.2.tgz", + "integrity": "sha512-K0VQKziLUWkVKiRVrx4a40iPaxTUefQmjtkQofBkYRcoaaL/8rhwDWww9qWbrgicNOgnpIsMxyNIUM4+n6dUIA==", + "dev": true + }, "node_modules/@types/node": { "version": "20.4.2", "resolved": "https://registry.npmjs.org/@types/node/-/node-20.4.2.tgz", @@ -3510,7 +3663,6 @@ "version": "5.0.1", "resolved": "https://registry.npmjs.org/ansi-regex/-/ansi-regex-5.0.1.tgz", "integrity": "sha512-quJQXlTSUGL2LH9SUXo8VwsY4soanhgo6LNSm84E1LBcE8s3O0wpdiRzyR9z/ZZJMlMWv37qOOb9pdJlMUEKFQ==", - "dev": true, "engines": { "node": ">=8" } @@ -3519,7 +3671,6 @@ "version": "4.3.0", "resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-4.3.0.tgz", "integrity": "sha512-zbB9rCJAT1rbjiVDb2hqKFHNYLxgtk8NURxZ3IZwD3F6NtxbXZQCnnSi1Lkx+IDohdPlFp222wVALIheZJQSEg==", - "dev": true, "dependencies": { "color-convert": "^2.0.1" }, @@ -3727,8 +3878,7 @@ "node_modules/balanced-match": { "version": "1.0.2", "resolved": "https://registry.npmjs.org/balanced-match/-/balanced-match-1.0.2.tgz", - "integrity": "sha512-3oSeUO0TMV67hN1AmbXsK4yaqU7tjiHlbxRDZOpH0KW9+CeX4bRAaX0Anxt0tx2MrpRpWwQaPwIlISEJhYU5Pw==", - "dev": true + "integrity": "sha512-3oSeUO0TMV67hN1AmbXsK4yaqU7tjiHlbxRDZOpH0KW9+CeX4bRAaX0Anxt0tx2MrpRpWwQaPwIlISEJhYU5Pw==" }, "node_modules/big-integer": { "version": "1.6.51", @@ -4097,7 +4247,6 @@ "version": "2.0.1", "resolved": "https://registry.npmjs.org/color-convert/-/color-convert-2.0.1.tgz", "integrity": "sha512-RRECPsj7iu/xb5oKYcsFHSppFNnsj/52OVTRKb4zP5onXwVF3zVmmToNcOfGC+CRDpfK/U584fMg38ZHCaElKQ==", - "dev": true, "dependencies": { "color-name": "~1.1.4" }, @@ -4152,6 +4301,14 @@ "node": ">= 0.8" } }, + "node_modules/commander": { + "version": "11.0.0", + "resolved": "https://registry.npmjs.org/commander/-/commander-11.0.0.tgz", + "integrity": "sha512-9HMlXtt/BNoYr8ooyjjNRdIilOTkVJXB+GhxMTtOKwk0R4j4lS4NpjuqmRxroBfnfTSHQIHQB7wryHhXarNjmQ==", + "engines": { + "node": ">=16" + } + }, "node_modules/component-emitter": { "version": "1.3.0", "resolved": "https://registry.npmjs.org/component-emitter/-/component-emitter-1.3.0.tgz", @@ -4247,7 +4404,6 @@ "version": "7.0.3", "resolved": "https://registry.npmjs.org/cross-spawn/-/cross-spawn-7.0.3.tgz", "integrity": "sha512-iRDPJKUPVEND7dHPO8rkbOnPpyDygcDFtWjpeWNCgy8WP2rXcxXL8TskReQl6OrB2G7+UJrags1q15Fudc7G6w==", - "dev": true, "dependencies": { "path-key": "^3.1.0", "shebang-command": "^2.0.0", @@ -4414,9 +4570,9 @@ } }, "node_modules/diff": { - "version": "4.0.2", - "resolved": "https://registry.npmjs.org/diff/-/diff-4.0.2.tgz", - "integrity": "sha512-58lmxKSA4BNyLz+HHMUzlOEpg09FV+ev6ZMe3vJihgdxzgcwZ8VoEEPmALCZG9LmqfVoNMMKpttIYTVG6uDY7A==", + "version": "5.1.0", + "resolved": "https://registry.npmjs.org/diff/-/diff-5.1.0.tgz", + "integrity": "sha512-D+mk+qE8VC/PAUrlAU34N+VfXev0ghe5ywmpqrawphmVZc1bEfn56uo9qpyGp1p4xpzOHkSW4ztBd6L7Xx4ACw==", "engines": { "node": ">=0.3.1" } @@ -4505,6 +4661,11 @@ "url": "https://github.com/motdotla/dotenv?sponsor=1" } }, + "node_modules/eastasianwidth": { + "version": "0.2.0", + "resolved": "https://registry.npmjs.org/eastasianwidth/-/eastasianwidth-0.2.0.tgz", + "integrity": "sha512-I88TYZWc9XiYHRQ4/3c5rjjfgkjhLyW2luGIheGERbNQ6OY7yTybanSpDXZa8y7VUP9YmDcYa+eyq4ca7iLqWA==" + }, "node_modules/ee-first": { "version": "1.1.1", "resolved": "https://registry.npmjs.org/ee-first/-/ee-first-1.1.1.tgz", @@ -4531,8 +4692,7 @@ "node_modules/emoji-regex": { "version": "8.0.0", "resolved": "https://registry.npmjs.org/emoji-regex/-/emoji-regex-8.0.0.tgz", - "integrity": "sha512-MSjYzcWNOA0ewAHpz0MxpYFvwg6yjy1NG3xteoqz644VCo/RPgnr1/GGt+ic3iJTzQ8Eu3TdM14SawnVUmGE6A==", - "dev": true + "integrity": "sha512-MSjYzcWNOA0ewAHpz0MxpYFvwg6yjy1NG3xteoqz644VCo/RPgnr1/GGt+ic3iJTzQ8Eu3TdM14SawnVUmGE6A==" }, "node_modules/enabled": { "version": "2.0.0", @@ -5132,6 +5292,32 @@ } } }, + "node_modules/foreground-child": { + "version": "3.1.1", + "resolved": "https://registry.npmjs.org/foreground-child/-/foreground-child-3.1.1.tgz", + "integrity": "sha512-TMKDUnIte6bfb5nWv7V/caI169OHgvwjb7V4WkeUvbQQdjr5rWKqHFiKWb/fcOwB+CzBT+qbWjvj+DVwRskpIg==", + "dependencies": { + "cross-spawn": "^7.0.0", + "signal-exit": "^4.0.1" + }, + "engines": { + "node": ">=14" + }, + "funding": { + "url": "https://github.com/sponsors/isaacs" + } + }, + "node_modules/foreground-child/node_modules/signal-exit": { + "version": "4.1.0", + "resolved": "https://registry.npmjs.org/signal-exit/-/signal-exit-4.1.0.tgz", + "integrity": "sha512-bzyZ1e88w9O1iNJbKnOlvYTrWPDl46O1bG0D3XInv+9tkPrxrN8jUUTiFlDkkmKWgn1M6CfIA13SuGqOa9Korw==", + "engines": { + "node": ">=14" + }, + "funding": { + "url": "https://github.com/sponsors/isaacs" + } + }, "node_modules/form-data": { "version": "4.0.0", "resolved": "https://registry.npmjs.org/form-data/-/form-data-4.0.0.tgz", @@ -5263,20 +5449,21 @@ } }, "node_modules/glob": { - "version": "7.2.3", - "resolved": "https://registry.npmjs.org/glob/-/glob-7.2.3.tgz", - "integrity": "sha512-nFR0zLpU2YCaRxwoCJvL6UvCH2JFyFVIvwTLsIf21AuHlMskA1hhTdk+LlYJtOlYt9v6dvszD2BGRqBL+iQK9Q==", - "dev": true, + "version": "10.3.9", + "resolved": "https://registry.npmjs.org/glob/-/glob-10.3.9.tgz", + "integrity": "sha512-2tU/LKevAQvDVuVJ9pg9Yv9xcbSh+TqHuTaXTNbQwf+0kDl9Fm6bMovi4Nm5c8TVvfxo2LLcqCGtmO9KoJaGWg==", "dependencies": { - "fs.realpath": "^1.0.0", - "inflight": "^1.0.4", - "inherits": "2", - "minimatch": "^3.1.1", - "once": "^1.3.0", - "path-is-absolute": "^1.0.0" + "foreground-child": "^3.1.0", + "jackspeak": "^2.3.5", + "minimatch": "^9.0.1", + "minipass": "^5.0.0 || ^6.0.2 || ^7.0.0", + "path-scurry": "^1.10.1" + }, + "bin": { + "glob": "dist/esm/bin.mjs" }, "engines": { - "node": "*" + "node": ">=16 || 14 >=14.17" }, "funding": { "url": "https://github.com/sponsors/isaacs" @@ -5294,6 +5481,28 @@ "node": ">=10.13.0" } }, + "node_modules/glob/node_modules/brace-expansion": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-2.0.1.tgz", + "integrity": "sha512-XnAIvQ8eM+kC6aULx6wuQiwVsnzsi9d3WxzV3FpWTGA19F621kwdbsAcFKXgKUHZWsy+mY6iL1sHTxWEFCytDA==", + "dependencies": { + "balanced-match": "^1.0.0" + } + }, + "node_modules/glob/node_modules/minimatch": { + "version": "9.0.3", + "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-9.0.3.tgz", + "integrity": "sha512-RHiac9mvaRw0x3AYRgDC1CxAP7HTcNrrECeA8YYJeWnpo+2Q5CegtZjaotWTWxDG3UeGA1coE05iH1mPjT/2mg==", + "dependencies": { + "brace-expansion": "^2.0.1" + }, + "engines": { + "node": ">=16 || 14 >=14.17" + }, + "funding": { + "url": "https://github.com/sponsors/isaacs" + } + }, "node_modules/globals": { "version": "13.20.0", "resolved": "https://registry.npmjs.org/globals/-/globals-13.20.0.tgz", @@ -5566,6 +5775,14 @@ "resolved": "https://registry.npmjs.org/inherits/-/inherits-2.0.4.tgz", "integrity": "sha512-k/vGaX4/Yla3WzyMCvTQOXYeIHvqOKtnqBduzTHpzpQZzAskKMhZ2K+EnBiSM9zGSoIFeMpXKxa4dYeZIQqewQ==" }, + "node_modules/ini": { + "version": "4.1.1", + "resolved": "https://registry.npmjs.org/ini/-/ini-4.1.1.tgz", + "integrity": "sha512-QQnnxNyfvmHFIsj7gkPcYymR8Jdw/o7mp5ZFihxn6h8Ci6fh3Dx4E1gPjpQEpIuPo9XVNY/ZUwh4BPMjGyL01g==", + "engines": { + "node": "^14.17.0 || ^16.13.0 || >=18.0.0" + } + }, "node_modules/ipaddr.js": { "version": "1.9.1", "resolved": "https://registry.npmjs.org/ipaddr.js/-/ipaddr.js-1.9.1.tgz", @@ -5632,7 +5849,6 @@ "version": "3.0.0", "resolved": "https://registry.npmjs.org/is-fullwidth-code-point/-/is-fullwidth-code-point-3.0.0.tgz", "integrity": "sha512-zymm5+u+sCsSWyD9qNaejV3DFvhCKclKdizYaJUuHA83RLjb7nSuGnddCHGv0hk+KY7BMAlsWeK4Ueg6EV6XQg==", - "dev": true, "engines": { "node": ">=8" } @@ -5741,8 +5957,7 @@ "node_modules/isexe": { "version": "2.0.0", "resolved": "https://registry.npmjs.org/isexe/-/isexe-2.0.0.tgz", - "integrity": "sha512-RHxMLp9lnKHGHRng9QFhRCMbYAcVpn69smSGcq3f36xjgVVWThj4qqLbTLlq7Ssj8B+fIQ1EuCEGI2lKsyQeIw==", - "dev": true + "integrity": "sha512-RHxMLp9lnKHGHRng9QFhRCMbYAcVpn69smSGcq3f36xjgVVWThj4qqLbTLlq7Ssj8B+fIQ1EuCEGI2lKsyQeIw==" }, "node_modules/istanbul-lib-coverage": { "version": "3.2.0", @@ -5827,6 +6042,23 @@ "retry": "0.6.0" } }, + "node_modules/jackspeak": { + "version": "2.3.5", + "resolved": "https://registry.npmjs.org/jackspeak/-/jackspeak-2.3.5.tgz", + "integrity": "sha512-Ratx+B8WeXLAtRJn26hrhY8S1+Jz6pxPMrkrdkgb/NstTNiqMhX0/oFVu5wX+g5n6JlEu2LPsDJmY8nRP4+alw==", + "dependencies": { + "@isaacs/cliui": "^8.0.2" + }, + "engines": { + "node": ">=14" + }, + "funding": { + "url": "https://github.com/sponsors/isaacs" + }, + "optionalDependencies": { + "@pkgjs/parseargs": "^0.11.0" + } + }, "node_modules/jest": { "version": "29.6.1", "resolved": "https://registry.npmjs.org/jest/-/jest-29.6.1.tgz", @@ -6065,6 +6297,26 @@ } } }, + "node_modules/jest-config/node_modules/glob": { + "version": "7.2.3", + "resolved": "https://registry.npmjs.org/glob/-/glob-7.2.3.tgz", + "integrity": "sha512-nFR0zLpU2YCaRxwoCJvL6UvCH2JFyFVIvwTLsIf21AuHlMskA1hhTdk+LlYJtOlYt9v6dvszD2BGRqBL+iQK9Q==", + "dev": true, + "dependencies": { + "fs.realpath": "^1.0.0", + "inflight": "^1.0.4", + "inherits": "2", + "minimatch": "^3.1.1", + "once": "^1.3.0", + "path-is-absolute": "^1.0.0" + }, + "engines": { + "node": "*" + }, + "funding": { + "url": "https://github.com/sponsors/isaacs" + } + }, "node_modules/jest-diff": { "version": "29.6.1", "resolved": "https://registry.npmjs.org/jest-diff/-/jest-diff-29.6.1.tgz", @@ -6345,6 +6597,26 @@ "node": "^14.15.0 || ^16.10.0 || >=18.0.0" } }, + "node_modules/jest-runtime/node_modules/glob": { + "version": "7.2.3", + "resolved": "https://registry.npmjs.org/glob/-/glob-7.2.3.tgz", + "integrity": "sha512-nFR0zLpU2YCaRxwoCJvL6UvCH2JFyFVIvwTLsIf21AuHlMskA1hhTdk+LlYJtOlYt9v6dvszD2BGRqBL+iQK9Q==", + "dev": true, + "dependencies": { + "fs.realpath": "^1.0.0", + "inflight": "^1.0.4", + "inherits": "2", + "minimatch": "^3.1.1", + "once": "^1.3.0", + "path-is-absolute": "^1.0.0" + }, + "engines": { + "node": "*" + }, + "funding": { + "url": "https://github.com/sponsors/isaacs" + } + }, "node_modules/jest-snapshot": { "version": "29.6.1", "resolved": "https://registry.npmjs.org/jest-snapshot/-/jest-snapshot-29.6.1.tgz", @@ -6838,6 +7110,14 @@ "node": "*" } }, + "node_modules/minipass": { + "version": "7.0.3", + "resolved": "https://registry.npmjs.org/minipass/-/minipass-7.0.3.tgz", + "integrity": "sha512-LhbbwCfz3vsb12j/WkWQPZfKTsgqIe1Nf/ti1pKjYESGLHIVjWU96G9/ljLH4F9mWNVhlQOm0VySdAWzf05dpg==", + "engines": { + "node": ">=16 || 14 >=14.17" + } + }, "node_modules/ms": { "version": "2.1.2", "resolved": "https://registry.npmjs.org/ms/-/ms-2.1.2.tgz", @@ -7222,7 +7502,6 @@ "version": "3.1.1", "resolved": "https://registry.npmjs.org/path-key/-/path-key-3.1.1.tgz", "integrity": "sha512-ojmeN0qd+y0jszEtoY48r0Peq5dwMEkIlCOu6Q5f41lfkswXuKtYrhgoTpLnyIcHm24Uhqx+5Tqm2InSwLhE6Q==", - "dev": true, "engines": { "node": ">=8" } @@ -7233,6 +7512,29 @@ "integrity": "sha512-LDJzPVEEEPR+y48z93A0Ed0yXb8pAByGWo/k5YYdYgpY2/2EsOsksJrq7lOHxryrVOn1ejG6oAp8ahvOIQD8sw==", "dev": true }, + "node_modules/path-scurry": { + "version": "1.10.1", + "resolved": "https://registry.npmjs.org/path-scurry/-/path-scurry-1.10.1.tgz", + "integrity": "sha512-MkhCqzzBEpPvxxQ71Md0b1Kk51W01lrYvlMzSUaIzNsODdd7mqhiimSZlr+VegAz5Z6Vzt9Xg2ttE//XBhH3EQ==", + "dependencies": { + "lru-cache": "^9.1.1 || ^10.0.0", + "minipass": "^5.0.0 || ^6.0.2 || ^7.0.0" + }, + "engines": { + "node": ">=16 || 14 >=14.17" + }, + "funding": { + "url": "https://github.com/sponsors/isaacs" + } + }, + "node_modules/path-scurry/node_modules/lru-cache": { + "version": "10.0.1", + "resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-10.0.1.tgz", + "integrity": "sha512-IJ4uwUTi2qCccrioU6g9g/5rvvVl13bsdczUUcqbciD9iLr095yj8DQKdObriEvuNSx325N1rV1O0sJFszx75g==", + "engines": { + "node": "14 || >=16.14" + } + }, "node_modules/path-to-regexp": { "version": "0.1.7", "resolved": "https://registry.npmjs.org/path-to-regexp/-/path-to-regexp-0.1.7.tgz", @@ -7753,6 +8055,26 @@ "url": "https://github.com/sponsors/isaacs" } }, + "node_modules/rimraf/node_modules/glob": { + "version": "7.2.3", + "resolved": "https://registry.npmjs.org/glob/-/glob-7.2.3.tgz", + "integrity": "sha512-nFR0zLpU2YCaRxwoCJvL6UvCH2JFyFVIvwTLsIf21AuHlMskA1hhTdk+LlYJtOlYt9v6dvszD2BGRqBL+iQK9Q==", + "dev": true, + "dependencies": { + "fs.realpath": "^1.0.0", + "inflight": "^1.0.4", + "inherits": "2", + "minimatch": "^3.1.1", + "once": "^1.3.0", + "path-is-absolute": "^1.0.0" + }, + "engines": { + "node": "*" + }, + "funding": { + "url": "https://github.com/sponsors/isaacs" + } + }, "node_modules/rrweb-cssom": { "version": "0.6.0", "resolved": "https://registry.npmjs.org/rrweb-cssom/-/rrweb-cssom-0.6.0.tgz", @@ -8007,7 +8329,6 @@ "version": "2.0.0", "resolved": "https://registry.npmjs.org/shebang-command/-/shebang-command-2.0.0.tgz", "integrity": "sha512-kHxr2zZpYtdmrN1qDjrrX/Z1rR1kG8Dx+gkpK1G4eXmvXswmcE1hTWBWYUzlraYw1/yZp6YuDY77YtvbN0dmDA==", - "dev": true, "dependencies": { "shebang-regex": "^3.0.0" }, @@ -8019,7 +8340,6 @@ "version": "3.0.0", "resolved": "https://registry.npmjs.org/shebang-regex/-/shebang-regex-3.0.0.tgz", "integrity": "sha512-7++dFhtcx3353uBaq8DDR4NuxBetBzC7ZQOhmTQInHEd6bSrXdiEyzCvG07Z44UYdLShWUyXt5M/yhz8ekcb1A==", - "dev": true, "engines": { "node": ">=8" } @@ -8183,7 +8503,20 @@ "version": "4.2.3", "resolved": "https://registry.npmjs.org/string-width/-/string-width-4.2.3.tgz", "integrity": "sha512-wKyQRQpjJ0sIp62ErSZdGsjMJWsap5oRNihHhu6G7JVO/9jIB6UyevL+tXuOqrng8j/cxKTWyWUwvSTriiZz/g==", - "dev": true, + "dependencies": { + "emoji-regex": "^8.0.0", + "is-fullwidth-code-point": "^3.0.0", + "strip-ansi": "^6.0.1" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/string-width-cjs": { + "name": "string-width", + "version": "4.2.3", + "resolved": "https://registry.npmjs.org/string-width/-/string-width-4.2.3.tgz", + "integrity": "sha512-wKyQRQpjJ0sIp62ErSZdGsjMJWsap5oRNihHhu6G7JVO/9jIB6UyevL+tXuOqrng8j/cxKTWyWUwvSTriiZz/g==", "dependencies": { "emoji-regex": "^8.0.0", "is-fullwidth-code-point": "^3.0.0", @@ -8197,7 +8530,18 @@ "version": "6.0.1", "resolved": "https://registry.npmjs.org/strip-ansi/-/strip-ansi-6.0.1.tgz", "integrity": "sha512-Y38VPSHcqkFrCpFnQ9vuSXmquuv5oXOKpGeT6aGrr3o3Gc9AlVa6JBfUSOCnbxGGZF+/0ooI7KrPuUSztUdU5A==", - "dev": true, + "dependencies": { + "ansi-regex": "^5.0.1" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/strip-ansi-cjs": { + "name": "strip-ansi", + "version": "6.0.1", + "resolved": "https://registry.npmjs.org/strip-ansi/-/strip-ansi-6.0.1.tgz", + "integrity": "sha512-Y38VPSHcqkFrCpFnQ9vuSXmquuv5oXOKpGeT6aGrr3o3Gc9AlVa6JBfUSOCnbxGGZF+/0ooI7KrPuUSztUdU5A==", "dependencies": { "ansi-regex": "^5.0.1" }, @@ -8343,6 +8687,26 @@ "node": ">=8" } }, + "node_modules/test-exclude/node_modules/glob": { + "version": "7.2.3", + "resolved": "https://registry.npmjs.org/glob/-/glob-7.2.3.tgz", + "integrity": "sha512-nFR0zLpU2YCaRxwoCJvL6UvCH2JFyFVIvwTLsIf21AuHlMskA1hhTdk+LlYJtOlYt9v6dvszD2BGRqBL+iQK9Q==", + "dev": true, + "dependencies": { + "fs.realpath": "^1.0.0", + "inflight": "^1.0.4", + "inherits": "2", + "minimatch": "^3.1.1", + "once": "^1.3.0", + "path-is-absolute": "^1.0.0" + }, + "engines": { + "node": "*" + }, + "funding": { + "url": "https://github.com/sponsors/isaacs" + } + }, "node_modules/text-hex": { "version": "1.0.0", "resolved": "https://registry.npmjs.org/text-hex/-/text-hex-1.0.0.tgz", @@ -8548,6 +8912,14 @@ } } }, + "node_modules/ts-node/node_modules/diff": { + "version": "4.0.2", + "resolved": "https://registry.npmjs.org/diff/-/diff-4.0.2.tgz", + "integrity": "sha512-58lmxKSA4BNyLz+HHMUzlOEpg09FV+ev6ZMe3vJihgdxzgcwZ8VoEEPmALCZG9LmqfVoNMMKpttIYTVG6uDY7A==", + "engines": { + "node": ">=0.3.1" + } + }, "node_modules/tslib": { "version": "2.6.2", "resolved": "https://registry.npmjs.org/tslib/-/tslib-2.6.2.tgz", @@ -8903,7 +9275,6 @@ "version": "2.0.2", "resolved": "https://registry.npmjs.org/which/-/which-2.0.2.tgz", "integrity": "sha512-BLI3Tl1TW3Pvl70l3yq3Y64i+awpwXqsGBYWkkqMtnbXgrMD+yj7rhW0kuEDxzJaYXGjEW5ogapKNMEKNMjibA==", - "dev": true, "dependencies": { "isexe": "^2.0.0" }, @@ -8976,6 +9347,23 @@ "url": "https://github.com/chalk/wrap-ansi?sponsor=1" } }, + "node_modules/wrap-ansi-cjs": { + "name": "wrap-ansi", + "version": "7.0.0", + "resolved": "https://registry.npmjs.org/wrap-ansi/-/wrap-ansi-7.0.0.tgz", + "integrity": "sha512-YVGIj2kamLSTxw6NsZjoBxfSwsn0ycdesmc4p+Q21c5zPuZ1pl+NfxVdxPtdHvmNVOQ6XSYG4AUtyt/Fi7D16Q==", + "dependencies": { + "ansi-styles": "^4.0.0", + "string-width": "^4.1.0", + "strip-ansi": "^6.0.0" + }, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/chalk/wrap-ansi?sponsor=1" + } + }, "node_modules/wrappy": { "version": "1.0.2", "resolved": "https://registry.npmjs.org/wrappy/-/wrappy-1.0.2.tgz", diff --git a/package.json b/package.json index 72ffc04..ea4398d 100644 --- a/package.json +++ b/package.json @@ -6,9 +6,10 @@ "lint": "eslint --ext .ts .", "prettier-format": "prettier --config .prettierrc **/*.ts --write", "test": "jest", - "build": "tsc -p .", + "build": "tsc -p . && npm run cp:26", "watch": "tsc -p . --watch", - "run": "node ./build/index.js" + "run": "node ./build/index.js", + "cp:26": "mkdir -p build/26/default-files/ && cp -n src/26/default-files/* build/26/default-files/" }, "keywords": [], "author": "", @@ -16,11 +17,15 @@ "dependencies": { "@datastructures-js/priority-queue": "^6.3.0", "axios": "^1.4.0", + "commander": "^11.0.0", "cors": "^2.8.5", + "diff": "^5.1.0", "discord.js": "^14.13.0", "dotenv": "^16.3.1", "express": "^4.18.2", + "glob": "^10.3.9", "helmet": "^7.0.0", + "ini": "^4.1.1", "jsdom": "^22.1.0", "memcached": "^2.2.2", "raw-socket": "github:algj/node-raw-socket", @@ -33,7 +38,10 @@ "@babel/core": "^7.22.9", "@babel/preset-env": "^7.22.9", "@babel/preset-typescript": "^7.22.5", + "@types/diff": "^5.0.5", "@types/express": "^4.17.17", + "@types/glob": "^8.1.0", + "@types/ini": "^1.3.31", "@types/jest": "^29.5.3", "@types/jsdom": "^21.1.2", "@types/memcached": "^2.2.7", diff --git a/src/26/README.md b/src/26/README.md new file mode 100644 index 0000000..a8e8542 --- /dev/null +++ b/src/26/README.md @@ -0,0 +1,65 @@ +# Challenge 26 - Write Your Own Git + +This challenge corresponds to the 26th part of the Coding Challenges series by John Crickett https://codingchallenges.fyi/challenges/challenge-git. + +## Description + +This is a Node.js implementation for the Git protocol. + +The code is somewhat inspired from the Go implementation of the Git Client. https://github.com/go-git/go-git + +The client currently supports the following commands. More information provided in [docs](docs/) folder. + +- [init](docs/init.md) +- [hash-object](docs/hash-object.md) +- [cat-file](docs/cat-file.md) +- [update-index](docs/update-index.md) +- [add](docs/update-index.md) +- [status](docs/status.md) +- [write-tree](docs/write-tree.md) +- [commit-tree](docs/commit-tree.md) +- [commit](docs/commit.md) +- [diff](docs/diff.md) + +Here is a brief description about some of the files/folders in this project: + +- [command/](commands/): Each command has its own implementation file present in this folder. + +- [docs/](docs/): Documentation about the usage of each command and how it was implemented. + +- [objects/](objects/): This directory contains code for encoding and decoding different types of [objects](https://git-scm.com/book/en/v2/Git-Internals-Git-Objects) supported by the Git protocol along with classes and functions to represent those objects and. + +- [git.ts](git.ts): The entry point to the Git cli. + +- [fileStatus.ts](fileStatus.ts): This file contains the code to find the status of the files present in the Staging and Worktree area. + +- [indexParser.ts](indexParser.ts): A class implementation to decode the `.git/index` file. Currently the parser handles all the index entries, and only the TreeExtension. View [this](https://github.com/git/git/blob/867b1c1bf68363bcfd17667d6d4b9031fa6a1300/Documentation/technical/index-format.txt) official index format for more details. + +- [jestHelpers.ts](jestHelpers.ts): Some functions used across the testing. + +- [utils.ts](utils.ts): This file contains some helper functions and miscellaneous functions used in different commands and functionalities. + +_**Note**: The refactoring that you can see is not achieved from the start. As I kept on developing and incorporating more commands, the refactoring automatically came into place. Most of the times, the code that got refactored was already being used somewhere and the new command/functionality needed the same code._ + +## Usage + +You can use the `ts-node` tool to run the Git client as follows: + +```bash +npx ts-node [options] [command] +``` + +Use the `--help` option to get more information on how to use the the Git client. +Or refer to the [docs](docs/) folder. + +## Run tests + +To run the tests for the Git Client, go to the root directory of this repository and run the following command: + +```bash +npm test src/26/ +``` + +## TODO + +- Complete [Step 6](https://codingchallenges.fyi/challenges/challenge-git/#step-6) of the challenge. diff --git a/src/26/__tests__/commands/catFile.test.ts b/src/26/__tests__/commands/catFile.test.ts new file mode 100644 index 0000000..d5ebf14 --- /dev/null +++ b/src/26/__tests__/commands/catFile.test.ts @@ -0,0 +1,102 @@ +import { + createDummyFile, + createTempGitRepo, + mockGetSignature +} from '../../jestHelpers'; +import catFile from '../../commands/catFile'; +import hashObject from '../../commands/hashObject'; +import writeTree from '../../commands/writeTree'; +import updateIndex from '../../commands/updateIndex'; +import { FileMode } from '../../enums'; +import { fileModeString } from '../../utils'; +import commitTree from '../../commands/commitTree'; + +function hashDummyFile(gitRoot: string): { + text: string; + filePath: string; + objectHash: string; +} { + const { text, filePath, expectedHash } = createDummyFile(); + const objectHash = hashObject({ gitRoot, write: true, file: filePath }); + expect(objectHash).toBe(expectedHash); + return { text, filePath, objectHash: objectHash }; +} + +describe('Testing catFile command', () => { + const gitRoot = createTempGitRepo(); + mockGetSignature(); + + let treeHash = ''; + let commitHash = ''; + + it('should output error for invalid args', () => { + expect(() => catFile({ gitRoot, object: 'object' })).toThrow(); + }); + + it('should output error for invalid object', () => { + expect(() => catFile({ gitRoot, object: 'object', t: true })).toThrow(); + }); + + it('should output correct content - blob', () => { + const { text, objectHash } = hashDummyFile(gitRoot); + const output = catFile({ + gitRoot, + p: true, + object: objectHash + }); + expect(output).toBe(text); + }); + + it('should output correct type - blob', () => { + const { objectHash } = hashDummyFile(gitRoot); + const output = catFile({ + gitRoot, + t: true, + object: objectHash + }); + expect(output).toBe('blob'); + }); + + it('should output correct content - tree', () => { + updateIndex({ gitRoot, files: ['.'] }); + treeHash = writeTree(gitRoot); + const output = catFile({ + gitRoot, + p: true, + object: treeHash + }); + expect(output).toContain(fileModeString.get(FileMode.REGULAR)); + }); + + it('should output correct type - tree', () => { + updateIndex({ gitRoot, files: ['.'] }); + const hash = writeTree(gitRoot); + const output = catFile({ + gitRoot, + t: true, + object: hash + }); + expect(output).toBe('tree'); + }); + + it('should output correct content - commit', () => { + const message = 'First commit'; + commitHash = commitTree({ gitRoot, treeHash, message: message }); + const output = catFile({ + gitRoot, + p: true, + object: commitHash + }); + expect(output).toContain(treeHash); + expect(output).toContain(message); + }); + + it('should output correct type - commit', () => { + const output = catFile({ + gitRoot, + t: true, + object: commitHash + }); + expect(output).toBe('commit'); + }); +}); diff --git a/src/26/__tests__/commands/commitTree.test.ts b/src/26/__tests__/commands/commitTree.test.ts new file mode 100644 index 0000000..c29b7ed --- /dev/null +++ b/src/26/__tests__/commands/commitTree.test.ts @@ -0,0 +1,81 @@ +import { randomBytes } from 'crypto'; +import { createTempGitRepo, mockGetSignature } from '../../jestHelpers'; +import fs from 'fs'; +import updateIndex from '../../commands/updateIndex'; +import path from 'path'; +import writeTree from '../../commands/writeTree'; +import commitTree from '../../commands/commitTree'; +import { decodeCommit } from '../../objects/commit'; + +describe('Testing commit tree command', () => { + const gitRoot = createTempGitRepo(); + mockGetSignature(); + + const files: { name: string; content: Buffer }[] = [ + { name: 'text1.txt', content: randomBytes(32) }, + { name: 'dir1/text2.txt', content: randomBytes(32) }, + { name: 'dir1/dir2/text3.txt', content: randomBytes(32) }, + { name: 'dir3/text4.txt', content: randomBytes(32) } + ]; + const files2: { name: string; content: Buffer }[] = [ + { name: 'text5.txt', content: randomBytes(32) } + ]; + + let firstTreeHash: string; + let firstCommitHash: string; + const firstMessage = 'First commit'; + + let secondTreeHash: string; + let secondCommitHash: string; + const secondMessage = 'Second commit'; + + it('should create the first commit object successfully', () => { + // Create files in git repo + files.forEach(({ name, content }) => { + fs.mkdirSync(path.dirname(name), { recursive: true }); + fs.writeFileSync(name, content); + }); + + // perform "git add ." command + updateIndex({ gitRoot, files: ['.'] }); + + // Perform write tree command + firstTreeHash = writeTree(gitRoot); + + firstCommitHash = commitTree({ + gitRoot, + treeHash: firstTreeHash, + message: firstMessage + }); + const commitObject = decodeCommit(gitRoot, firstCommitHash); + expect(commitObject.message).toBe(firstMessage); + expect(commitObject.treeHash).toBe(firstTreeHash); + expect(commitObject.parentHashes).toStrictEqual([]); + }); + + it('should create the second commit object with parent successfully', () => { + // Create new files in git repo + files2.forEach(({ name, content }) => { + fs.mkdirSync(path.dirname(name), { recursive: true }); + fs.writeFileSync(name, content); + }); + + // perform "git add ." command + updateIndex({ gitRoot, files: ['.'] }); + + // Perform write tree command + secondTreeHash = writeTree(gitRoot); + + secondCommitHash = commitTree({ + gitRoot, + treeHash: secondTreeHash, + parents: [firstCommitHash], + message: secondMessage + }); + + const commitObject = decodeCommit(gitRoot, secondCommitHash); + expect(commitObject.message).toBe(secondMessage); + expect(commitObject.treeHash).toBe(secondTreeHash); + expect(commitObject.parentHashes).toStrictEqual([firstCommitHash]); + }); +}); diff --git a/src/26/__tests__/commands/hashObjects.test.ts b/src/26/__tests__/commands/hashObjects.test.ts new file mode 100644 index 0000000..f215e9b --- /dev/null +++ b/src/26/__tests__/commands/hashObjects.test.ts @@ -0,0 +1,53 @@ +// https://git-scm.com/book/en/v2/Git-Internals-Git-Objects +import path from 'path'; +import fs from 'fs'; +import { createDummyFile, createTempGitRepo } from '../../jestHelpers'; +import hashObject from '../../commands/hashObject'; +import { Readable } from 'stream'; + +describe('Testing hashObject command', () => { + const gitRoot = createTempGitRepo(); + + it('should output error on invalid args', () => { + expect(() => hashObject({ gitRoot })).toThrow(); + }); + + it('should create hash for file', () => { + const { filePath, expectedHash } = createDummyFile(); + + const hash = hashObject({ gitRoot, file: filePath }); + expect(hash.trim()).toBe(expectedHash); + }); + + it('should handle stdin option', () => { + const text = 'what is up, doc?'; + const expectedHash = 'bd9dbf5aae1a3862dd1526723246b20206e5fc37'; + const stdinStream = Readable.from(Buffer.from(text)); + + const hash = hashObject({ + gitRoot, + stdin: stdinStream, + readFromStdin: true + }); + expect(hash.trim()).toBe(expectedHash); + stdinStream.destroy(); + }); + + it('should handle write option', () => { + const { filePath, expectedHash } = createDummyFile(); + const pathToBlob = path.join( + './.git/objects', + expectedHash.substring(0, 2), + expectedHash.substring(2, expectedHash.length) + ); + + const hash = hashObject({ + gitRoot, + file: filePath, + write: true + }); + + expect(hash.trim()).toBe(expectedHash); + expect(fs.existsSync(pathToBlob)).toBeTruthy(); + }); +}); diff --git a/src/26/__tests__/commands/init.test.ts b/src/26/__tests__/commands/init.test.ts new file mode 100644 index 0000000..2ff8ae1 --- /dev/null +++ b/src/26/__tests__/commands/init.test.ts @@ -0,0 +1,50 @@ +import { randomBytes } from 'crypto'; +import os from 'os'; +import path from 'path'; +import fs from 'fs'; + +import init from '../../commands/init'; + +describe('Testing init command', () => { + let tempPath: string; + + beforeAll(() => { + tempPath = path.join(os.tmpdir(), randomBytes(2).toString('hex')); + }); + + afterAll(() => { + fs.rmSync(tempPath, { recursive: true, force: true }); + }); + + it('should initialize empty repository successfully', (done) => { + const output = init(tempPath); + + expect(fs.existsSync(path.join(tempPath, '.git'))).toBeTruthy(); + expect(fs.existsSync(path.join(tempPath, '.git', 'HEAD'))).toBeTruthy(); + expect(fs.existsSync(path.join(tempPath, '.git', 'config'))).toBeTruthy(); + expect( + fs.existsSync(path.join(tempPath, '.git', 'description')) + ).toBeTruthy(); + expect(fs.existsSync(path.join(tempPath, '.git', 'hooks'))).toBeTruthy(); + expect(fs.existsSync(path.join(tempPath, '.git', 'info'))).toBeTruthy(); + expect( + fs.existsSync(path.join(tempPath, '.git', 'info', 'exclude')) + ).toBeTruthy(); + expect(fs.existsSync(path.join(tempPath, '.git', 'objects'))).toBeTruthy(); + expect( + fs.existsSync(path.join(tempPath, '.git', 'objects', 'info')) + ).toBeTruthy(); + expect( + fs.existsSync(path.join(tempPath, '.git', 'objects', 'pack')) + ).toBeTruthy(); + expect(fs.existsSync(path.join(tempPath, '.git', 'refs'))).toBeTruthy(); + + expect(output).toContain('Initialized'); + done(); + }); + + it('should output reinitialized text when git repo is already initialized', () => { + const output = init(tempPath); + expect(output).toContain('Reinitialized'); + }); +}); diff --git a/src/26/__tests__/commands/updateIndex.test.ts b/src/26/__tests__/commands/updateIndex.test.ts new file mode 100644 index 0000000..29bbf84 --- /dev/null +++ b/src/26/__tests__/commands/updateIndex.test.ts @@ -0,0 +1,80 @@ +import fs from 'fs'; +import { randomBytes } from 'crypto'; +import updateIndex from '../../commands/updateIndex'; +import { createTempGitRepo } from '../../jestHelpers'; +import IndexParser from '../../indexParser'; +import { RELATIVE_PATH_TO_INDEX_FILE } from '../../constants'; +import path from 'path'; + +describe('Testing update-index command', () => { + const gitRoot = createTempGitRepo(); + + it('should throw error on invalid args', () => { + expect(() => updateIndex({ gitRoot, files: undefined })).toThrow(); + expect(() => updateIndex({ gitRoot, files: [] })).toThrow(); + expect(() => + updateIndex({ gitRoot, files: [randomBytes(2).toString()] }) + ).toThrow(); + }); + + it('should add file to index and create a new index file', () => { + expect( + fs.existsSync(path.join(gitRoot, RELATIVE_PATH_TO_INDEX_FILE)) + ).toBeFalsy(); + + const filename = 'cc.txt'; + fs.closeSync(fs.openSync(filename, 'w')); + const expectedStat = fs.statSync(filename); + + updateIndex({ gitRoot, files: [filename] }); + + expect( + fs.existsSync(path.join(gitRoot, RELATIVE_PATH_TO_INDEX_FILE)) + ).toBeTruthy(); + + const index = new IndexParser(gitRoot).parse(); + + expect(index.entries.length).toBe(1); + + const entry = index.getEntry(filename); + + expect(entry).not.toBe(undefined); + if (entry) { + expect(entry.name).toBe(filename); + expect(entry.ino).toBe(expectedStat.ino); + expect(entry.dev).toBe(expectedStat.dev); + expect(entry.size).toBe(expectedStat.size); + expect(entry.gid).toBe(expectedStat.gid); + expect(entry.uid).toBe(expectedStat.uid); + } + }); + + it('should add multiple files to index successfully', () => { + const files: { name: string; content: Buffer }[] = [ + { name: 'dir1/text1.txt', content: randomBytes(32) }, + { name: 'dir2/subdir2/text2.txt', content: randomBytes(64) } + ]; + + files.forEach(({ name, content }) => { + fs.mkdirSync(path.dirname(name), { recursive: true }); + fs.writeFileSync(name, content); + }); + const fileNames: string[] = files.map(({ name }) => { + return name; + }); + + updateIndex({ gitRoot, files: fileNames }); + + const index = new IndexParser(gitRoot).parse(); + + files.forEach(({ name, content }) => { + const entry1 = index.getEntry(name); + + expect(entry1).not.toBe(undefined); + if (entry1) { + expect(entry1.name).toBe(name); + expect(entry1.size).toBe(content.byteLength); + } + }); + }); +}); diff --git a/src/26/__tests__/commands/writeTree.test.ts b/src/26/__tests__/commands/writeTree.test.ts new file mode 100644 index 0000000..c6f39a4 --- /dev/null +++ b/src/26/__tests__/commands/writeTree.test.ts @@ -0,0 +1,65 @@ +import { randomBytes } from 'crypto'; +import { createTempGitRepo } from '../../jestHelpers'; +import fs from 'fs'; +import path from 'path'; +import updateIndex from '../../commands/updateIndex'; +import writeTree from '../../commands/writeTree'; +import { decodeTree } from '../../objects/tree'; +import IndexParser from '../../indexParser'; +import { CachedTree } from '../../objects/cachedTree'; + +describe('Testing write tree command', () => { + const gitRoot = createTempGitRepo(); + const files: { name: string; content: Buffer }[] = [ + { name: 'text1.txt', content: randomBytes(32) }, + { name: 'dir1/text2.txt', content: randomBytes(32) }, + { name: 'dir1/dir2/text3.txt', content: randomBytes(32) }, + { name: 'dir3/text4.txt', content: randomBytes(32) } + ]; + const rootNodeChildrenSize = 3; + const rootNodeSubtreeCount = 2; + const rootNodeEntryCount = 4; + + beforeAll(() => { + // Create files in git repo + files.forEach(({ name, content }) => { + fs.mkdirSync(path.dirname(name), { recursive: true }); + fs.writeFileSync(name, content); + }); + + // perform "git add ." command + updateIndex({ gitRoot, files: ['.'] }); + }); + + it('should write tree successfully', () => { + const treeHash = writeTree(gitRoot); + const index = new IndexParser(gitRoot).parse(); + const tree = decodeTree(gitRoot, treeHash); + + // Check properties of root + expect(tree.root.children.size).toBe(rootNodeChildrenSize); + expect(tree.root.entryCount).toBe(rootNodeEntryCount); + expect(tree.root.subTreeCount).toBe(rootNodeSubtreeCount); + expect(tree.root.hash).toBe(treeHash); + + // Check if all the files are present in Tree + files.forEach(({ name }) => { + const node = tree.getNode(name); + const indexEntry = index.getEntry(name); + + expect(node).not.toBe(undefined); + expect(indexEntry).not.toBe(undefined); + if (node && indexEntry) { + expect(node.path).toBe(name); + expect(node.hash).toBe(indexEntry.hash); + } + }); + + const cachedTree = new CachedTree(); + expect(tree.root.calculateHash(gitRoot, false, cachedTree)).toBe(treeHash); + cachedTree.entries = cachedTree.entries.sort((a, b) => + a.name.localeCompare(b.name) + ); + expect(cachedTree).toStrictEqual(index.cache); + }); +}); diff --git a/src/26/__tests__/indexParser/indexParser.test.ts b/src/26/__tests__/indexParser/indexParser.test.ts new file mode 100644 index 0000000..21b6865 --- /dev/null +++ b/src/26/__tests__/indexParser/indexParser.test.ts @@ -0,0 +1,30 @@ +import fs from 'fs'; +import IndexParser from '../../indexParser'; +import { createTempGitRepo } from '../../jestHelpers'; +import { RELATIVE_PATH_TO_INDEX_FILE } from '../../constants'; +import path from 'path'; +import { randomBytes } from 'crypto'; + +describe('Testing indexParser', () => { + const dir = path.join(__dirname, 'testFiles'); + const files = fs.readdirSync(dir); + + const gitRoot = createTempGitRepo(); + + it('should throw error when invalid gitRoot is provided', () => { + expect(() => new IndexParser(randomBytes(16).toString()).parse()).toThrow(); + }); + + files.forEach((file) => { + it(`should parse index file: ${file}`, () => { + // Update the index file + const contents = fs.readFileSync(path.join(dir, file)); + fs.writeFileSync( + path.join(gitRoot, RELATIVE_PATH_TO_INDEX_FILE), + contents + ); + + expect(() => new IndexParser(gitRoot).parse()).not.toThrow(); + }); + }); +}); diff --git a/src/26/__tests__/indexParser/testFiles/index1 b/src/26/__tests__/indexParser/testFiles/index1 new file mode 100644 index 0000000..fa2ce23 Binary files /dev/null and b/src/26/__tests__/indexParser/testFiles/index1 differ diff --git a/src/26/__tests__/indexParser/testFiles/index2 b/src/26/__tests__/indexParser/testFiles/index2 new file mode 100644 index 0000000..87f868a Binary files /dev/null and b/src/26/__tests__/indexParser/testFiles/index2 differ diff --git a/src/26/__tests__/indexParser/testFiles/index3 b/src/26/__tests__/indexParser/testFiles/index3 new file mode 100644 index 0000000..6f59be4 Binary files /dev/null and b/src/26/__tests__/indexParser/testFiles/index3 differ diff --git a/src/26/__tests__/indexParser/testFiles/index4 b/src/26/__tests__/indexParser/testFiles/index4 new file mode 100644 index 0000000..8f585a7 Binary files /dev/null and b/src/26/__tests__/indexParser/testFiles/index4 differ diff --git a/src/26/__tests__/signature.test.ts b/src/26/__tests__/signature.test.ts new file mode 100644 index 0000000..bdc8e1f --- /dev/null +++ b/src/26/__tests__/signature.test.ts @@ -0,0 +1,52 @@ +import { + Signature, + decodeSignature, + getTimeAndTimeZone +} from '../objects/signature'; + +describe('Testing Signature related classes and functions', () => { + const name = 'John Doe'; + const email = 'example@gmail.com'; + + const date = new Date(); + const timeInSec = 1696322721; + date.setTime(timeInSec * 1000); + + it('should return correct output - toString() method', () => { + const signature = new Signature(name, email, date); + const str = signature.toString(); + + expect(str).toContain(`${name} <${email}> ${timeInSec}`); + }); + + it('should match timezone format', () => { + const date = new Date(); + const str = getTimeAndTimeZone(date); + const regex = /[+-](\d{2})(\d{2})/; + + expect(regex.test(str)).toBeTruthy(); + }); + + it('should decode signature successfully', () => { + const str = `${name} <${email}> ${timeInSec} +0530`; + const signature = decodeSignature(str); + + expect(signature.name).toBe(name); + expect(signature.email).toBe(email); + expect(signature.timestamp.getTime()).toBe(date.getTime()); + }); + + it('should convert date object to time and timezone offset', () => { + const date = new Date(); + const seconds = Math.floor(date.getTime() / 1000); + const output = getTimeAndTimeZone(date).trim(); + const [sec, timezone] = output.split(' '); + + expect(parseInt(sec)).toBe(seconds); + const timezoneInMin = + (timezone[0] === '+' ? -1 : 1) * + (parseInt(timezone.substring(1, 3)) * 60 + + parseInt(timezone.substring(3, 5))); + expect(timezoneInMin).toBe(date.getTimezoneOffset()); + }); +}); diff --git a/src/26/__tests__/utils.test.ts b/src/26/__tests__/utils.test.ts new file mode 100644 index 0000000..089b214 --- /dev/null +++ b/src/26/__tests__/utils.test.ts @@ -0,0 +1,96 @@ +import path from 'path'; +import { createTempGitRepo, mockGetSignature } from '../jestHelpers'; +import fs from 'fs'; +import { randomBytes, randomInt } from 'crypto'; +import { + getCurrentBranchName, + getFileStats, + getFiles, + getIgnoredGlobPatterns, + getSignature, + isValidSHA1, + parseObjectHeader +} from '../utils'; +import { GitObjectType } from '../types'; + +describe('Testing utils', () => { + const gitRoot = createTempGitRepo(); + mockGetSignature(); + + const gitignoreContent = ['.idea/**', '**/node_modules/**', '**/build/**']; + const files: { name: string; content: Buffer }[] = [ + { name: 'text1.txt', content: randomBytes(32) }, + { name: 'dir1/text2.txt', content: randomBytes(32) }, + { name: 'node_modules/module/index.ts', content: randomBytes(32) } + ]; + const names = files.map(({ name }) => { + return name; + }); + names.push('.gitignore'); + + beforeAll(() => { + fs.writeFileSync( + path.join(gitRoot, '.gitignore'), + gitignoreContent.join('\r\n') + ); + files.forEach(({ name, content }) => { + fs.mkdirSync(path.dirname(path.join(gitRoot, name)), { recursive: true }); + fs.writeFileSync(name, content); + }); + }); + + it('should parse .gitignore successfully', () => { + const data = getIgnoredGlobPatterns(gitRoot); + gitignoreContent.forEach((pattern) => { + expect(data.indexOf(pattern)).toBeGreaterThanOrEqual(0); + }); + }); + + it('should get files and successfully', () => { + const data = getFiles(gitRoot, gitRoot); + + data.forEach((file) => { + expect(names.indexOf(file)).toBeGreaterThanOrEqual(0); + }); + }); + + it('should get file stats successfully', () => { + const data = getFileStats(gitRoot); + + data.forEach((stat, key) => { + expect(names.indexOf(key)).toBeGreaterThanOrEqual(0); + }); + }); + + it('should throw error when no HEAD file is found', () => { + expect(() => getCurrentBranchName(randomBytes(2).toString())).toThrow(); + }); + + it('should get the current branch name successfully', () => { + const data = getCurrentBranchName(gitRoot); + expect(data.trim()).toBe('master'); + }); + + it('should get signature successfully', () => { + const signature = getSignature(); + expect(signature.email).not.toBe(undefined); + expect(signature.name).not.toBe(undefined); + }); + + it('should parse object header successfully', () => { + const objectType: GitObjectType = 'commit'; + const byteLength = randomInt(100, 10000); + const buffer = Buffer.from(`${objectType} ${byteLength}`); + const header = parseObjectHeader(buffer); + + expect(header.type).toBe(objectType); + expect(header.length).toBe(byteLength); + }); + + const invalidHashes = ['', '12', '12 ', '12~', 'h']; + invalidHashes.forEach((hash) => { + it(`should return false for invalid hash ${hash}`, () => { + expect(isValidSHA1(hash)).toBeFalsy(); + }); + }); +}); diff --git a/src/26/commands/catFile.ts b/src/26/commands/catFile.ts new file mode 100644 index 0000000..862e7c1 --- /dev/null +++ b/src/26/commands/catFile.ts @@ -0,0 +1,103 @@ +import { SPACE, NULL } from '../constants'; +import { fileModeString, fileType, parseObject } from '../utils'; + +interface CatFileArgs { + /** + * Absolute path to the root of the Git repo. + * + * @type {string} + */ + gitRoot: string; + + /** + * The hash value corresponding to the object. + * + * @type {string} + */ + object: string; + + /** + * Return the type only. + * + * @type {?boolean} + */ + t?: boolean; + + /** + * Return the content of the given object. + * + * @type {?boolean} + */ + p?: boolean; +} + +function parseTreeFile(data: Buffer): string { + const output: string[] = []; + + for (let i = 0; i < data.length; ) { + // Format of each entry: + // + const modeStartPos = i; + while (data[i] !== SPACE) { + i++; + } + const mode = parseInt(data.subarray(modeStartPos, i).toString(), 8); + i++; + + const filenameStartPos = i; + while (data[i] !== NULL) { + i++; + } + const filename = data.subarray(filenameStartPos, i).toString(); + i++; + + const hash = data.subarray(i, i + 20).toString('hex'); + i += 20; + output.push( + `${fileModeString.get(mode)} ${fileType.get(mode)} ${hash} ${filename}` + ); + } + + return output.join('\r\n'); +} + +/** + * Main function to perform the cat-file command. + * Supported file types: + * - blob + * - tree + * + * @param {CatFileArgs} { gitRoot, object, t = false, p = false } + * @returns {string} + */ +function catFile({ + gitRoot, + object, + t = false, + p = false +}: CatFileArgs): string { + // Make sure exact one option is provided + if ((t && p) || (!t && !p)) { + throw new Error('Invalid usage'); + } + + const gitObject = parseObject(gitRoot, object); + + // The user asked for `type` only + if (t) { + return gitObject.type; + } + + // The user asked for contents of the file + switch (gitObject.type) { + case 'blob': + case 'commit': + return gitObject.data.toString(); + case 'tree': + return parseTreeFile(gitObject.data); + } + + throw new Error(`File ${gitObject.type} not supported`); +} + +export default catFile; diff --git a/src/26/commands/commit.ts b/src/26/commands/commit.ts new file mode 100644 index 0000000..802b1c1 --- /dev/null +++ b/src/26/commands/commit.ts @@ -0,0 +1,52 @@ +import path from 'path'; +import { getBranchHeadReference, getCurrentBranchName } from '../utils'; +import commitTree from './commitTree'; +import writeTree from './writeTree'; +import { RELATIVE_PATH_TO_REF_HEADS_DIR } from '../constants'; +import fs from 'fs'; + +/** + * The main function that performs the 'commit' command. + * + * @param {string} gitRoot + * @param {string} message + * @returns {string} + */ +function commit(gitRoot: string, message: string): string { + // Make sure a valid message is provided + if (message.length === 0) { + throw new Error('Please provide a valid message'); + } + + // Get current branch name and a ref to the parent hash if present. + const branch = getCurrentBranchName(gitRoot); + const ref = getBranchHeadReference(gitRoot, branch); + const parents: string[] = []; + if (ref !== undefined) { + parents.push(ref); + } + + // Create the tree object from the index + const treeHash = writeTree(gitRoot); + + // Create the commit object + const hash = commitTree({ gitRoot, treeHash, message, parents }); + + // Update the head for the current branch + const pathToRef = path.join(gitRoot, RELATIVE_PATH_TO_REF_HEADS_DIR, branch); + fs.writeFileSync(pathToRef, hash + '\n'); + + // Create the output string + let str = ''; + if (parents.length === 0) { + // First commit of this branch + str += `[${branch} (root-commit) ${hash.substring(0, 7)} ${message} \r\n`; + } else { + str += `[${branch} ${hash.substring(0, 7)}] ${message}\r\n`; + } + // TODO: Show number of files changed, total insertions and deletions + + return str; +} + +export default commit; diff --git a/src/26/commands/commitTree.ts b/src/26/commands/commitTree.ts new file mode 100644 index 0000000..d31b9c9 --- /dev/null +++ b/src/26/commands/commitTree.ts @@ -0,0 +1,97 @@ +import { createHash } from 'crypto'; +import { Commit } from '../objects/commit'; +import { getSignature, verifyObject } from '../utils'; +import zlib from 'zlib'; +import fs from 'fs'; +import path from 'path'; +import { RELATIVE_PATH_TO_OBJECT_DIR } from '../constants'; +import stream from 'stream'; + +export interface CommitTreeArgs { + /** + * The absolute path to the Git repo. + * + * @type {string} + */ + gitRoot: string; + + /** + * The hash of the tree that will be saved with the Commit object. + * + * @type {string} + */ + treeHash: string; + + /** + * List of parent objects. + * + * @type {?string[]} + */ + parents?: string[]; + + /** + * Message that will be used as Commit message. + * + * @type {?string} + */ + message?: string; + + /** + * Optionally read from stdin if no message is provided + * + * @type {?stream.Readable} + */ + stdin?: stream.Readable; +} + +function commitTree({ + gitRoot, + treeHash, + parents = [], + message, + stdin = process.stdin +}: CommitTreeArgs): string { + // Verify the hashes provided by the user + treeHash = verifyObject(gitRoot, treeHash, 'tree'); + for (let i = 0; i < parents.length; i++) { + parents[i] = verifyObject(gitRoot, parents[i], 'commit'); + } + + // If message is not provided in args then read from stdin + if (message === undefined) { + const buffer = stdin.read() as Buffer; + if (buffer === null) { + throw new Error('No message provided!'); + } + message = buffer.toString(); + } + + // Get author and committer details from '~/.gitconfig' file + const signature = getSignature(); + + const commitObject = new Commit({ + author: signature, + committer: signature, + message, + treeHash: treeHash, + parentHashes: parents + }); + + // Create hash and store the commit object + const store = commitObject.encode(); + const hash = createHash('sha1').update(store).digest('hex'); + + const zlibContent = zlib.deflateSync(store); + const pathToBlob = path.join( + gitRoot, + RELATIVE_PATH_TO_OBJECT_DIR, + hash.substring(0, 2), + hash.substring(2, hash.length) + ); + fs.mkdirSync(path.dirname(pathToBlob), { recursive: true }); + fs.writeFileSync(pathToBlob, zlibContent); + + return hash; +} + +export default commitTree; diff --git a/src/26/commands/diff.ts b/src/26/commands/diff.ts new file mode 100644 index 0000000..c843bdf --- /dev/null +++ b/src/26/commands/diff.ts @@ -0,0 +1,162 @@ +import { FileMode, FileStatusCode } from '../enums'; +import { getFileStatus } from '../fileStatus'; +import IndexParser from '../indexParser'; +import { fileModeString, getCurrentBranchName, parseObject } from '../utils'; +import fs from 'fs'; +import hashObject from './hashObject'; +import { createTwoFilesPatch } from 'diff'; +import { + BoldEnd, + BoldStart, + ColorReset, + FgCyan, + FgGreen, + FgRed +} from '../constants'; + +interface FileInfo { + /** + * The relative path of the file from the root of the Git repo. + * + * @type {string} + */ + name: string; + + /** + * The content of the file. + * + * @type {string} + */ + content: string; + + /** + * The hash of the file as per the hashObject function. + * + * @type {string} + */ + hash: string; +} + +/** + * Calculates the diff between the given FileInfo objects. + * + * @param {FileInfo} a + * @param {FileInfo} b + * @param {FileStatusCode} status + * @param {FileMode} mode + * @returns {string} + */ +function diffFile( + a: FileInfo, + b: FileInfo, + status: FileStatusCode, + mode: FileMode +): string { + // Create header + let str = `${BoldStart}diff --git a/${a.name} b/${b.name}\n`; + + if (status === FileStatusCode.DELETED) { + str += `deleted file mode ${fileModeString.get(mode)}\n`; + str += `index ${a.hash.substring(0, 7)}..${b.hash.substring(0, 7)}\n`; + } else { + str += `index ${a.hash.substring(0, 7)}..${b.hash.substring( + 0, + 7 + )} ${fileModeString.get(mode)}\n`; + } + + // Call the diff package and create the patch using the name and content. + const changes = createTwoFilesPatch( + `a/${a.name}`, + `b/${b.name}`, + a.content, + b.content + ); + + // Skip the first line from changes + const split = changes.split(/\r\n|\n/).slice(1); + + // End bold lines in header + split[1] = `${split[1]}${BoldEnd}`; + + // Add color to lines + for (let i = 2; i < split.length; i++) { + const line = split[i]; + if ( + line.substring(0, 2) === '@@' && + line.substring(line.length - 2, line.length) === '@@' + ) { + split[i] = `${FgCyan}${line}${ColorReset}`; + } else if (line[0] === '+') { + split[i] = `${FgGreen}${line}${ColorReset}`; + } else if (line[0] === '-') { + split[i] = `${FgRed}${line}${ColorReset}`; + } + } + + return str + split.join('\n'); +} + +/** + * Main function that performs the 'diff' command + * + * @export + * @param {string} gitRoot + * @returns {string} + */ +export function gitDiff(gitRoot: string): string { + // Get status of files + const branch = getCurrentBranchName(gitRoot); + const index = new IndexParser(gitRoot).parse(); + const files = getFileStatus(gitRoot, branch); + + let str = ''; + + files.forEach((status) => { + switch (status.worktree) { + case FileStatusCode.UNMODIFIED: + case FileStatusCode.UNTRACKED: + case FileStatusCode.ADDED: + break; + case FileStatusCode.DELETED: { + const e = index.getEntry(status.name)!; + const gitObject = parseObject(gitRoot, e.hash); + + const a: FileInfo = { + name: status.name, + content: gitObject.data.toString(), + hash: e.hash + }; + + const b: FileInfo = { + name: '/dev/null', + content: '', + hash: ''.padStart(20, '0') + }; + str += diffFile(a, b, status.worktree, e.mode); + break; + } + case FileStatusCode.MODIFIED: { + const e = index.getEntry(status.name)!; + const gitObject = parseObject(gitRoot, e.hash); + + const a: FileInfo = { + name: status.name, + content: gitObject.data.toString(), + hash: e.hash + }; + + const b: FileInfo = { + name: status.name, + content: fs.readFileSync(status.name, 'utf-8'), + hash: hashObject({ gitRoot, file: status.name }) + }; + + str += diffFile(a, b, status.worktree, e.mode); + break; + } + } + }); + + return str; +} diff --git a/src/26/commands/hashObject.ts b/src/26/commands/hashObject.ts new file mode 100644 index 0000000..f966e4e --- /dev/null +++ b/src/26/commands/hashObject.ts @@ -0,0 +1,87 @@ +// https://git-scm.com/docs/git-hash-object +import zlib from 'zlib'; + +import { createHash } from 'crypto'; +import fs from 'fs'; +import path from 'path'; +import { GitObjectType } from '../types'; +import stream from 'stream'; +import { RELATIVE_PATH_TO_OBJECT_DIR } from '../constants'; + +interface HashObjectArgs { + /** + * The absolute path to the root of the Git repo. + * + * @type {string} + */ + gitRoot: string; + + /** + * The type of git Object. + * + * @type {?GitObjectType} + */ + type?: GitObjectType; + + /** + * Whether to save the object to the storage. + * + * @type {?boolean} + */ + write?: boolean; + + /** + * Read content of the file from the stdin. + * + * @type {?boolean} + */ + readFromStdin?: boolean; + + /** + * Path to file + * + * @type {?string} + */ + file?: string; + stdin?: stream.Readable; +} + +function hashObject({ + gitRoot, + type = 'blob', + write = false, + readFromStdin = false, + file = undefined, + stdin = process.stdin +}: HashObjectArgs): string { + let content: Buffer; + + // Prepare content + if (readFromStdin) { + content = stdin.read() as Buffer; + } else if (file) { + content = fs.readFileSync(file); + } else { + throw new Error('Invalid args. No file provided'); + } + + const header = Buffer.from(`${type} ${content.byteLength}\0`); + const store = Buffer.concat([header, content]); + const hash = createHash('sha1').update(store).digest('hex'); + + if (write) { + const zlibContent = zlib.deflateSync(store); + const pathToBlob = path.join( + gitRoot, + RELATIVE_PATH_TO_OBJECT_DIR, + hash.substring(0, 2), + hash.substring(2, hash.length) + ); + fs.mkdirSync(path.dirname(pathToBlob), { recursive: true }); + fs.writeFileSync(pathToBlob, zlibContent); + } + + return hash; +} + +export default hashObject; diff --git a/src/26/commands/init.ts b/src/26/commands/init.ts new file mode 100644 index 0000000..49e5482 --- /dev/null +++ b/src/26/commands/init.ts @@ -0,0 +1,57 @@ +// https://git-scm.com/book/en/v2/Git-Internals-Plumbing-and-Porcelain + +import fs from 'fs'; +import path from 'path'; + +const reinitializeText = 'Reinitialized existing Git repository in '; +const DEFAULT_CONFIG = fs.readFileSync( + path.join(__dirname, '..', 'default-files', 'default-config') +); +const DEFAULT_DESCRIPTION = fs.readFileSync( + path.join(__dirname, '..', 'default-files', 'default-description') +); +const DEFAULT_EXCLUDE = fs.readFileSync( + path.join(__dirname, '..', 'default-files', 'default-exclude') +); +const DEFAULT_HEAD = fs.readFileSync( + path.join(__dirname, '..', 'default-files', 'default-HEAD') +); + +/** + * Main function that performs the initialization of a git repo. + * + * @param {?string} [directory] path where the git repo should be initialized + * @returns {string} + */ +function init(directory?: string): string { + let gitDir = path.join(process.cwd(), '.git'); + + if (directory) { + if (path.isAbsolute(directory)) { + gitDir = path.join(directory, '.git'); + } else { + gitDir = path.join(process.cwd(), directory, '.git'); + } + } + + if (fs.existsSync(gitDir)) { + return reinitializeText + gitDir; + } + + fs.mkdirSync(gitDir, { recursive: true }); + + fs.writeFileSync(path.join(gitDir, 'HEAD'), DEFAULT_HEAD); + fs.writeFileSync(path.join(gitDir, 'config'), DEFAULT_CONFIG); + fs.writeFileSync(path.join(gitDir, 'description'), DEFAULT_DESCRIPTION); + fs.mkdirSync(path.join(gitDir, 'hooks')); + fs.mkdirSync(path.join(gitDir, 'info')); + fs.writeFileSync(path.join(gitDir, 'info', 'exclude'), DEFAULT_EXCLUDE); + fs.mkdirSync(path.join(gitDir, 'objects')); + fs.mkdirSync(path.join(gitDir, 'objects', 'info')); + fs.mkdirSync(path.join(gitDir, 'objects', 'pack')); + fs.mkdirSync(path.join(gitDir, 'refs')); + + return `Initialized empty repository in ${gitDir}`; +} + +export default init; diff --git a/src/26/commands/status.ts b/src/26/commands/status.ts new file mode 100644 index 0000000..6e473f6 --- /dev/null +++ b/src/26/commands/status.ts @@ -0,0 +1,95 @@ +import { ColorReset, FgGreen, FgRed } from '../constants'; +import path from 'path'; +import { getCurrentBranchName } from '../utils'; +import { FileStatusCode } from '../enums'; +import { getFileStatus } from '../fileStatus'; +import { FileStatus } from '../types'; + +/** + * Prepares a human readable output from a list of FileStatus objects. + * + * @param {string} gitRoot + * @param {string} branch + * @param {FileStatus[]} files + * @returns {string} + */ +function prepareOutput( + gitRoot: string, + branch: string, + files: Map +): string { + const cwd = path.relative(gitRoot, process.cwd()); + const arr = [...files.values()].sort((a, b) => a.name.localeCompare(b.name)); + let str = `On branch ${branch}`; + let changesToBeCommitted = ''; + let changesNotStaged = ''; + let untrackedFiles = ''; + + arr.forEach((e) => { + const name = path.relative(cwd, e.name); + + switch (e.worktree) { + case FileStatusCode.UNMODIFIED: + break; + case FileStatusCode.UNTRACKED: + untrackedFiles += `\t${name}\r\n`; + break; + case FileStatusCode.DELETED: + changesNotStaged += `\tdeleted: ${name}\r\n`; + break; + case FileStatusCode.MODIFIED: + changesNotStaged += `\tmodified: ${name}\r\n`; + break; + default: + throw new Error(`Invalid worktree status ${e}`); + } + + switch (e.staging) { + case FileStatusCode.ADDED: + changesToBeCommitted += `\tnew file: ${name}\r\n`; + break; + case FileStatusCode.DELETED: + changesToBeCommitted += `\tdeleted: ${name}\r\n`; + break; + case FileStatusCode.MODIFIED: + changesToBeCommitted += `\tmodified: ${name}\r\n`; + break; + case FileStatusCode.UNMODIFIED: + case FileStatusCode.UNTRACKED: + break; + } + }); + + if (changesToBeCommitted.length > 0) { + str += `\r\nChanges to be committed:\r\n${FgGreen}${changesToBeCommitted}${ColorReset}`; + } + if (changesNotStaged.length > 0) { + str += `\r\nChanges not staged for commit:\r\n${FgRed}${changesNotStaged}${ColorReset}`; + } + if (untrackedFiles.length > 0) { + str += `\r\nUntracked files:\r\n${FgRed}${untrackedFiles}${ColorReset}`; + } + + if (changesToBeCommitted.length === 0) { + if (untrackedFiles.length === 0 && changesNotStaged.length === 0) { + str += '\r\nnothing to commit, working tree clean'; + } else { + str += '\r\nno changes added to commit'; + } + } + return str; +} + +/** + * Main function that handles the `status` command. + * + * @param {string} gitRoot + * @returns {string} + */ +function status(gitRoot: string): string { + const branch = getCurrentBranchName(gitRoot); + const files = getFileStatus(gitRoot, branch); + return prepareOutput(gitRoot, branch, files); +} + +export default status; diff --git a/src/26/commands/updateIndex.ts b/src/26/commands/updateIndex.ts new file mode 100644 index 0000000..9b5405a --- /dev/null +++ b/src/26/commands/updateIndex.ts @@ -0,0 +1,95 @@ +// https://github.com/git/git/blob/867b1c1bf68363bcfd17667d6d4b9031fa6a1300/Documentation/technical/index-format.txt#L38 + +import fs from 'fs'; +import { RELATIVE_PATH_TO_INDEX_FILE } from '../constants'; +import { Index, IndexEntry, createIndexEntry } from '../objects'; +import IndexParser from '../indexParser'; +import path from 'path'; +import { getFiles } from '../utils'; + +interface UpdateIndexArgs { + /** + * The absolute path to the root of the Git repo. + * + * @type {string} + */ + gitRoot: string; + + /** + * List of files or directories. + * + * @type {?string[]} + */ + files?: string[]; +} + +/** + * Main function that performs the 'update-index' command + * + * @param {UpdateIndexArgs} { gitRoot, files } + * @returns {string} + */ +function updateIndex({ gitRoot, files }: UpdateIndexArgs): string { + // Ensure files or directories are provided + if (files === undefined || files.length === 0) { + throw new Error('Invalid args'); + } + + let filesToAdd: string[] = []; + + files.forEach((value) => { + const isDir = fs.statSync(value).isDirectory(); + // If the given path is a directory, then get all the files present inside. + if (isDir) { + filesToAdd.push(...getFiles(gitRoot, value)); + return; + } + filesToAdd.push(value); + }); + + // Ensuring unique files + filesToAdd = [...new Set(filesToAdd)]; + + // If index file is already is present + if (fs.existsSync(path.join(gitRoot, RELATIVE_PATH_TO_INDEX_FILE))) { + const index = new IndexParser(gitRoot).parse(); + + // Handle deleted files + const paths = index.entries.map((e) => { + return e.name; + }); + paths.forEach((value) => { + if (!fs.existsSync(path.join(gitRoot, value))) { + index.remove(value); + } + }); + + // Handle files that are present in working tree + filesToAdd.forEach((file) => { + const pathRelativeToGitRoot = path.relative(gitRoot, file); + const entry = createIndexEntry(gitRoot, pathRelativeToGitRoot); + + // Remove the previous information, then Add the new information + index.remove(entry.name); + index.add(entry); + }); + + // Finally save to disk and return nothing + index.saveToDisk(); + return ''; + } + + // We are creating a new index file + const entries: IndexEntry[] = []; + filesToAdd.forEach((file) => { + const pathRelativeToGitRoot = path.relative(gitRoot, file); + entries.push(createIndexEntry(gitRoot, pathRelativeToGitRoot)); + }); + + const index = new Index({ signature: 'DIRC', version: 2 }, entries); + + index.saveToDisk(); + return ''; +} + +export default updateIndex; diff --git a/src/26/commands/writeTree.ts b/src/26/commands/writeTree.ts new file mode 100644 index 0000000..d3576dc --- /dev/null +++ b/src/26/commands/writeTree.ts @@ -0,0 +1,32 @@ +import { CachedTree } from '../objects/cachedTree'; +import IndexParser from '../indexParser'; +import { Tree } from '../objects/tree'; + +/** + * The main function that performs the 'write-tree' command. + * + * @param {string} gitRoot + * @returns {string} + */ +function writeTree(gitRoot: string): string { + const index = new IndexParser(gitRoot).parse(); + + const tree = new Tree(); + tree.build(index); + + // Change this to false for debugging purposes + const writeToDisk = true; + const cachedTree = new CachedTree(); + + const hash = tree.root.calculateHash(gitRoot, writeToDisk, cachedTree); + + // Ensure entries in cachedTree are sorted by their names. + cachedTree.entries.sort((a, b) => a.name.localeCompare(b.name)); + + // Invalidate the previous cachedTree and update the index. + index.cache = cachedTree; + index.saveToDisk(); + return hash; +} + +export default writeTree; diff --git a/src/26/constants.ts b/src/26/constants.ts new file mode 100644 index 0000000..e3bfe9c --- /dev/null +++ b/src/26/constants.ts @@ -0,0 +1,41 @@ +import path from 'path'; + +export const NULL = Buffer.from('\0')[0]; +export const SPACE = Buffer.from(' ')[0]; +export const LF = Buffer.from('\n')[0]; + +export const SHA1Regex = /^[a-fA-F0-9]{40}$/; + +export const RELATIVE_PATH_TO_INDEX_FILE = '.git/index'; +export const RELATIVE_PATH_TO_HEAD_FILE = '.git/HEAD'; +export const RELATIVE_PATH_TO_OBJECT_DIR = '.git/objects'; +export const RELATIVE_PATH_TO_REF_HEADS_DIR = '.git/refs/heads'; +export const PATH_TO_GIT_CONFIG = path.join( + (process.env.HOME || process.env.HOMEPATH || process.env.USERPROFILE) ?? '', + '.gitconfig' +); +export const DEFAULT_IGNORE_PATTERNS = ['.git/**']; + +// Constants used when parsing .git/index file +export const PREFIX_SIZE = 62; +export const CTIME_OFFSET = 0; +export const CTIME_NANO_OFFSET = 4; +export const MTIME_OFFSET = 8; +export const MTIME_NANO_OFFSET = 12; +export const DEV_OFFSET = 16; +export const INO_OFFSET = 20; +export const MODE_OFFSET = 24; +export const UID_OFFSET = 28; +export const GID_OFFSET = 32; +export const FILES_SIZE_OFFSET = 36; +export const HASH_OFFSET = 40; +export const FLAGS_OFFSET = 60; +export const NAME_OFFSET = 62; + +// Constants for beautify text output to console +export const FgGreen = '\x1b[32m'; +export const ColorReset = '\x1b[0m'; +export const FgRed = '\x1b[31m'; +export const FgCyan = '\x1b[36m'; +export const BoldStart = '\x1b[1m'; +export const BoldEnd = `\x1b[22m`; diff --git a/src/26/default-files/default-HEAD b/src/26/default-files/default-HEAD new file mode 100644 index 0000000..cb089cd --- /dev/null +++ b/src/26/default-files/default-HEAD @@ -0,0 +1 @@ +ref: refs/heads/master diff --git a/src/26/default-files/default-config b/src/26/default-files/default-config new file mode 100644 index 0000000..d545cda --- /dev/null +++ b/src/26/default-files/default-config @@ -0,0 +1,7 @@ +[core] + repositoryformatversion = 0 + filemode = false + bare = false + logallrefupdates = true + symlinks = false + ignorecase = true diff --git a/src/26/default-files/default-description b/src/26/default-files/default-description new file mode 100644 index 0000000..498b267 --- /dev/null +++ b/src/26/default-files/default-description @@ -0,0 +1 @@ +Unnamed repository; edit this file 'description' to name the repository. diff --git a/src/26/default-files/default-exclude b/src/26/default-files/default-exclude new file mode 100644 index 0000000..a5196d1 --- /dev/null +++ b/src/26/default-files/default-exclude @@ -0,0 +1,6 @@ +# git ls-files --others --exclude-from=.git/info/exclude +# Lines that start with '#' are comments. +# For a project mostly in C, the following would be a good set of +# exclude patterns (uncomment them if you want to use them): +# *.[oa] +# *~ diff --git a/src/26/docs/cat-file.md b/src/26/docs/cat-file.md new file mode 100644 index 0000000..2ec451d --- /dev/null +++ b/src/26/docs/cat-file.md @@ -0,0 +1,39 @@ +## Command: `cat-file` + +## Usage + +```bash +# Using ts-node +npx ts-node cat-file [options] + +# Using node +node cat-file [options] +``` + +### Options + +- `object` + + The hash of the object to show. + +- `-p` + + Pretty-print the contents of `object` based on its type. + +- `-t` + + Instead of the content, show the object type identified by `object`. + +## Description + +Provide content or type information for repository objects. + +## Approach + +1. First check if the given hash corresponds to a valid object or not. This is done via the helper function `parseObject()` present in [utils.ts](../utils.ts). + + **Note**: Objects stored in the packfile are not supported. Refer to the JSDOC of the `parseObject` function for more details. + +2. The `parseObject` function will given us the `type`, `byteLength` and the `data` in form of the Buffer. + +3. The `blob` and `commit` type files can be printed as is. The `tree` type file requires some level of decoding. diff --git a/src/26/docs/commit-tree.md b/src/26/docs/commit-tree.md new file mode 100644 index 0000000..6372079 --- /dev/null +++ b/src/26/docs/commit-tree.md @@ -0,0 +1,39 @@ +## Command: `commit-tree` + +## Usage + +```bash +# Using ts-node +npx ts-node commit-tree [options] + +# Using node +node commit-tree [options] +``` + +### Options + +- `-m ` + + A paragraph in the commit log message. + +- `-p ` + + List of parent objects + +- `tree` + + An existing tree object. + +## Description + +This command creates a new commit object from the given tree object, message and optional parent objects and displays the hash of the created commit object. + +## Approach + +1. First verify all the hashes provided in the arguments. This is done by calling the `verifyObject()` function present in [utils.ts](../utils.ts) + +2. Next, ensure a valid message is provided by the user via command line arguments, otherwise read from stdin. + +3. Next, get the author and committer signature. This is handled by the `getSignature()` method present in [utils.ts](../utils.ts). The details are generally stored in the `.gitconfig` file present in the system. which follows the format of an `ini` file. I used the [ini](https://www.npmjs.com/package/ini) package to do the heavy lifting. + +4. Next, we create a commit object and encode it. And, finally we store the commit object in the storage and return the hash. This is handled by the [Commit class](../objects/commit.ts) diff --git a/src/26/docs/commit.md b/src/26/docs/commit.md new file mode 100644 index 0000000..d86c9b9 --- /dev/null +++ b/src/26/docs/commit.md @@ -0,0 +1,31 @@ +## Command: `commit` + +## Usage + +```bash +# Using ts-node +npx ts-node commit + +# Using node +node commit +``` + +### Options + +- `message` + + Use the given `message` as the commit message. + +## Description + +Record changes to the repository. + +## Approach + +1. First get the current branch name and the reference to the latest commit if present. This is handled by the functions `getCurrentBranchName()` and `getBranchHeadReference()` respectively. The current branch name is stored in the `.git/HEAD` file, and the reference is present at the location `.git/refs/heads/`. The reference might not be there if there were no previous commits. + +2. Next, create and store the Tree object from current state of the index file and get the hash. + +3. Next build a store a Commit object using the tree hash from Step 2 and parent hash (if any) from Step 1. + +4. Finally update the head reference. diff --git a/src/26/docs/diff.md b/src/26/docs/diff.md new file mode 100644 index 0000000..fcb60ac --- /dev/null +++ b/src/26/docs/diff.md @@ -0,0 +1,25 @@ +## Command: `diff` + +## Usage + +```bash +# Using ts-node +npx ts-node diff + +# Using node +node diff +``` + +## Description + +Show changes between index and working tree. + +## Approach + +1. First we retrieve the status of files from current working tree using the function `getFileStatus()` present in [fileStatus.ts](../fileStatus.ts). + +2. Then iterate over the files and create a diff string iteratively. Skip files that are not of out interests. + +3. To create the diff for a file, I used the function `createTwoFilesPatch()` from the [diff](https://www.npmjs.com/package/diff) package, performed some cleanup and added some text color to match the actual `git diff` command output. This is handled by the function `diffFile()` present in [diff.ts](../commands/diff.ts) + +_Note: The output of the original `git diff` command and the diff command implemented here might be slightly different._ diff --git a/src/26/docs/hash-object.md b/src/26/docs/hash-object.md new file mode 100644 index 0000000..4d6b56b --- /dev/null +++ b/src/26/docs/hash-object.md @@ -0,0 +1,36 @@ +## Command: `hash-object` + +## Usage + +```bash +# Using ts-node +npx ts-node hash-object [options] [file] + +# Using node +node hash-object [options] [file] +``` + +### Options + +- `-t ` + + Type of object to be created, default: `blob`. Possible values: `blob`, `commit`, `tree`, `tag`. + +- `-w, --write` + + Actually write the object into the object database. + +- `--stdin` + + Read the object from standard input instead of from a file. + +- `file` + + File path in case stdin is not provided. + +## Description + +This command computes the Object ID used in the Git storage system for the provided file/text. +Optionally creates an object from a file. + +Source doc - https://git-scm.com/book/en/v2/Git-Internals-Git-Objects : **Object Storage** section. diff --git a/src/26/docs/init.md b/src/26/docs/init.md new file mode 100644 index 0000000..bedd601 --- /dev/null +++ b/src/26/docs/init.md @@ -0,0 +1,29 @@ +## Command: `init` + +## Usage + +```bash +# Using ts-node +npx ts-node init [directory] + +# Using node +node init [directory] +``` + +### Options + +- `directory` + + The directory where the init command will be executed. If this directory does not exist, it will be created. + +## Description + +This command created an empty Git repository or reinitialize an existing one. + +Refer to [this](https://git-scm.com/book/en/v2/Git-Internals-Plumbing-and-Porcelain) official doc for more information about the `.git` directory. + +## Approach + +1. Added support for the command through [git.ts](../git.ts). The code for init command is present in [init.ts](../commands/init.ts). + +2. Created a separate folder [default-file](../default-files/) that contains some of the files required as per the Git protocol. The contents of these files are copied to the `.git` directory. To ensure that these folder is also copied to the build dir, I updated the build script present in [package.json](/package.json) diff --git a/src/26/docs/status.md b/src/26/docs/status.md new file mode 100644 index 0000000..b237aec --- /dev/null +++ b/src/26/docs/status.md @@ -0,0 +1,27 @@ +## Command: `status` + +## Usage + +```bash +# Using ts-node +npx ts-node status + +# Using node +node status +``` + +## Description + +Show the working tree status. + +## Approach + +1. The main part of the command lies in finding the status for a given file in the Worktree and Staging area. I created a separated file [fileStatus.ts](../fileStatus.ts) for this, since the same functionality will be needed in the `diff` command as well. + +2. First we need to get the information about the latest Commit (if present) and compare the Staging area with the Commit's Tree. This is handled by the function `diffCommitWithStaging()` present in [fileStatus.ts](../fileStatus.ts). The code is quite self explanatory. + + To handle parsing the Commit object and the Tree object, I created the decode function in the [commit.ts](../objects/commit.ts) and [tree.ts](../objects/tree.ts) respectively. + +3. Next, we need to compare the Staging area and the Worktree area. This is handled by the function `diffStagingWithWorktree()` also present in [fileStatus.ts](../fileStatus.ts). + +4. After combining both the above outputs, we finally prepare a human readable string that shows the status of the Worktree and the Staging area. This is handled by the function `prepareOutput()` present in [status.ts](../commands/status.ts) diff --git a/src/26/docs/update-index.md b/src/26/docs/update-index.md new file mode 100644 index 0000000..6213faa --- /dev/null +++ b/src/26/docs/update-index.md @@ -0,0 +1,45 @@ +## Command: `update-index` / `add` + +## Usage + +Using `update-index` command + +```bash +# Using ts-node +npx ts-node update-index + +# Using node +node update-index +``` + +Using `add` command + +```bash +# Using ts-node +npx ts-node add + +# Using node +node add +``` + +**Note**: In theory, both the commands are different. +The `add` command is a **Porcelain** command while the `update-index` command is a **Plumbing** command. +For the sake of this distinction, I added support for both of them, even though they perform the same operations in the context of my codebase. + +### Options + +- `files` + + Files to act on. These can also include directories. If you want to add all the files to the index use `update-index .`. + +## Description + +Register file contents in the working tree to the index. + +## Approach + +1. First find the path of all the files relative to the root of the Git repo. Since we need to support directories, I leveraged the [glob](https://www.npmjs.com/package/glob) package to list files while also excluding the files present in the `.gitignore` file. The function that performs this is `getFiles()` present in [utils.ts](../utils.ts) file. + +2. If there is no previous `.git/index` file present, then we create a new one and add the Index Entries to it using the [Index class](../objects/index.ts). Otherwise we first remove the file from the index and then insert it again. + +View [this](https://github.com/git/git/blob/867b1c1bf68363bcfd17667d6d4b9031fa6a1300/Documentation/technical/index-format) official doc to know more about index format. diff --git a/src/26/docs/write-tree.md b/src/26/docs/write-tree.md new file mode 100644 index 0000000..c6c0571 --- /dev/null +++ b/src/26/docs/write-tree.md @@ -0,0 +1,28 @@ +## Command: `write-tree` + +## Usage + +```bash +# Using ts-node +npx ts-node write-tree + +# Using node +node write-tree +``` + +## Description + +Create a Tree object from the current index and displays the hash for the Tree object. + +## Approach + +1. First parse the index and get all the files present in the index. We use the [IndexParser class](../indexParser.ts) for this. + +2. Create a tree and insert all the files that we got from the previous step. The code for this can be found in [tree.ts](../objects/tree.ts). + + The tree follows the structure of [Patricia Trie](https://www.geeksforgeeks.org/implementing-patricia-trie-in-java/). + A given `Tree` object consists of `TreeNode` where each node can have multiple children nodes. The leaf node represents a File in the WorkTree while the internal nodes represent the directories. + +3. Finally save the tree to the disk and return the hash of the tree created. This is done by calling the `calculateHash()` method on the `Tree` class instance. This function accepts an optional boolean argument `writeToDisk` which tells the function to create new objects for the directories. It also accepts another argument `cachedTree` which stores the entries corresponding to directories and is used while updating the index file. + +To learn more how Git uses Tree objects, view [this](https://git-scm.com/book/en/v2/Git-Internals-Git-Objects) : **Tree Objects section**. diff --git a/src/26/enums.ts b/src/26/enums.ts new file mode 100644 index 0000000..4def7af --- /dev/null +++ b/src/26/enums.ts @@ -0,0 +1,32 @@ +export enum Stage { + ZERO = 0, + MERGED = 1, + OUR_MODE = 2, + THEIR_MODE = 3 +} + +// https://git-scm.com/docs/index-format +export enum EntryType { + REGULAR = 0b1000, + SYMBOLIC_LINK = 0b1010, + GITLINK = 0b1110 +} + +// https://github.com/go-git/go-git/blob/809f9df1b76258a311a20c76d346e86aca0a08f8/plumbing/filemode/filemode.go#L14 +export enum FileMode { + EMPTY = 0, + DIR = 0o0040000, + REGULAR = 0o0100644, + DEPRECATED = 0o0100664, + EXECUTABLE = 0o0100755, + SYMLINK = 0o0120000, + SUBMODULE = 0o0160000 +} + +export enum FileStatusCode { + UNTRACKED = 'U', + MODIFIED = 'M', + DELETED = 'D', + ADDED = 'A', + UNMODIFIED = ' ' +} diff --git a/src/26/fileStatus.ts b/src/26/fileStatus.ts new file mode 100644 index 0000000..eec010a --- /dev/null +++ b/src/26/fileStatus.ts @@ -0,0 +1,172 @@ +import path from 'path'; +import hashObject from './commands/hashObject'; +import { RELATIVE_PATH_TO_INDEX_FILE } from './constants'; +import { FileStatusCode } from './enums'; +import IndexParser from './indexParser'; +import { decodeCommit } from './objects/commit'; +import { Tree, decodeTree } from './objects/tree'; +import { getBranchHeadReference, getFileStats } from './utils'; +import fs from 'fs'; +import { DiffEntry, FileStatus } from './types'; + +export function diffCommitWithStaging( + gitRoot: string, + branch: string +): DiffEntry[] { + const index = new IndexParser(gitRoot).parse(); + const files: DiffEntry[] = []; + + // Retrieve the hash of the latest commit for this branch. + const commitHash = getBranchHeadReference(gitRoot, branch); + let tree: Tree | undefined = undefined; + + if (commitHash) { + const commitObject = decodeCommit(gitRoot, commitHash); + tree = decodeTree(gitRoot, commitObject.treeHash); + } + + // No previous commit present. + // Status of all files => 'ADDED' + if (tree === undefined) { + index.entries.forEach((e) => { + files.push({ name: e.name, status: FileStatusCode.ADDED }); + }); + return files; + } + + // Sort files w.r.t name + const treeFiles = [...tree.map.entries()].sort((a, b) => + a[0].localeCompare(b[0]) + ); + + treeFiles.forEach(([name, node]) => { + const indexEntry = index.getEntry(name); + // File present in tree and index + if (indexEntry) { + files.push({ + name, + status: + indexEntry.hash === node.hash + ? FileStatusCode.UNMODIFIED + : FileStatusCode.MODIFIED + }); + } else { + // File present in tree but not in index => 'DELETED' + files.push({ + name, + status: FileStatusCode.DELETED + }); + } + // Remove the file form index entries. + // Make sure not to save the index to disk. + // This is required to process the 'ADDED' files. + index.remove(name); + }); + + // File present in index but not in tree => 'ADDED' + index.entries.forEach((e) => { + files.push({ name: e.name, status: FileStatusCode.ADDED }); + }); + + return files; +} + +export function diffStagingWithWorktree(gitRoot: string): DiffEntry[] { + const files: DiffEntry[] = []; + const index = new IndexParser(gitRoot).parse(); + const fileStats = getFileStats(gitRoot); + + index.entries.forEach((e) => { + const stat = fileStats.get(e.name); + // File is present in index and Worktree + if (stat) { + const hash = hashObject({ gitRoot, write: false, file: e.name }); + + files.push({ + name: e.name, + status: + e.hash === hash ? FileStatusCode.UNMODIFIED : FileStatusCode.MODIFIED + }); + } else { + // File present in index but not in Worktree => 'DELETED' + files.push({ name: e.name, status: FileStatusCode.DELETED }); + } + fileStats.delete(e.name); + }); + + // File present in Worktree but not in index => 'UNTRACKED' + fileStats.forEach((value) => { + files.push({ + name: value.pathFromGitRoot, + status: FileStatusCode.UNTRACKED + }); + }); + + return files; +} + +/** + * This function finds the status of the files present in Staging and Worktree. + * It returns a Map where: + * - key: the path to the file, and + * - value: FileStatus object. + * + * @export + * @param {string} gitRoot + * @returns {Map} + */ +export function getFileStatus( + gitRoot: string, + branch: string +): Map { + const files = new Map(); + + // No index file is present. All the files will be set as untracked. + if (!fs.existsSync(path.join(gitRoot, RELATIVE_PATH_TO_INDEX_FILE))) { + const fileStats = getFileStats(gitRoot); + fileStats.forEach((file) => { + files.set(file.pathFromGitRoot, { + name: file.pathFromGitRoot, + staging: FileStatusCode.UNTRACKED, + worktree: FileStatusCode.UNTRACKED + }); + }); + + return files; + } + + const diff1 = diffCommitWithStaging(gitRoot, branch); + + // Assuming files present in Staging area have UNMODIFIED worktree status. + // The worktree status might be updated later on. + diff1.forEach((value) => { + const file: FileStatus = { + name: value.name, + staging: value.status, + worktree: FileStatusCode.UNMODIFIED + }; + + files.set(value.name, file); + }); + + const diff2 = diffStagingWithWorktree(gitRoot); + + diff2.forEach((value) => { + let file = files.get(value.name); + + // file not present in commit or index + if (file === undefined) { + file = { + name: value.name, + staging: FileStatusCode.UNTRACKED, + worktree: FileStatusCode.UNTRACKED + }; + } else { + file.worktree = value.status; + } + + files.set(value.name, file); + }); + + return files; +} diff --git a/src/26/git.ts b/src/26/git.ts new file mode 100644 index 0000000..86a5641 --- /dev/null +++ b/src/26/git.ts @@ -0,0 +1,184 @@ +import { program } from 'commander'; +import init from './commands/init'; +import hashObject from './commands/hashObject'; +import catFile from './commands/catFile'; +import fs from 'fs'; +import updateIndex from './commands/updateIndex'; +import status from './commands/status'; +import writeTree from './commands/writeTree'; +import path from 'path'; +import commitTree from './commands/commitTree'; +import commit from './commands/commit'; +import { gitDiff } from './commands/diff'; + +/** + * Finds the path to the root of current git repo if exists. + * + * @returns {string} + */ +function ensureGitRepo(): string { + let root = process.cwd(); + let pathToGit: string; + + // Keep going to the parent dir until we find a .git dir + while (root !== '/') { + pathToGit = path.join(root, '.git'); + if (fs.existsSync(pathToGit)) { + return root; + } + root = path.dirname(root); + } + + pathToGit = path.join(root, '.git'); + if (fs.existsSync(pathToGit)) { + return root; + } + + // No .git dir found + process.stderr.write( + 'fatal: not a git repository (or any of the parent directories): .git\r\n' + ); + process.exit(1); +} + +/** + * Prints the output received from the cb to stdout. + * Logs error to console if thrown by cb. + * + * @param {() => string} cb + * @param {boolean} [newLine=true] + */ +function wrapper(cb: () => string, newLine: boolean = true): void { + try { + const output = cb(); + process.stdout.write(output + (newLine ? '\r\n' : '')); + process.exit(0); + } catch (e) { + const err = e as Error; + console.error(err); + process.exit(1); + } +} + +program + .command('init') + .argument( + '[directory]', + 'The init command will be run inside this directory. If this directory does not exist, it will be created.' + ) + .description('Create an empty Git repository or reinitialize an existing one') + .action((directory) => { + wrapper(() => init(directory)); + }); + +program + .command('hash-object') + .description('Compute object ID and optionally create an object from a file') + .option('-t ', 'Type of object to be created', 'blob') + .option('-w', '--write', 'Actually write the object into the object database') + .option( + '--stdin', + 'Read the object from standard input instead of from a file.' + ) + .argument('[file]', 'File path in case stdin is not provided') + .action((file, { w, stdin, type }) => { + const gitRoot = ensureGitRepo(); + wrapper( + () => + hashObject({ + gitRoot, + type, + write: w, + readFromStdin: stdin, + file, + stdin: process.stdin + }), + true + ); + }); + +program + .command('cat-file') + .description('Provide content or type information for repository objects') + .argument('', 'The name of the object to show.') + .option( + '-t', + 'Instead of the content, show the object type identified by ' + ) + .option('-p', 'Pretty-print the contents of based on its type') + .action((object, { t, p }) => { + const gitRoot = ensureGitRepo(); + wrapper(() => catFile({ gitRoot, object, t, p }), t); + }); + +program + .command('update-index') + .description('Register file contents in the working tree to the index') + .argument('', 'Files to act on') + .action((files) => { + const gitRoot = ensureGitRepo(); + wrapper(() => updateIndex({ gitRoot, files: files }), false); + }); + +program + .command('add') + .description('Add file contents to the index') + .argument('', 'Files to add content from') + .action((files) => { + const gitRoot = ensureGitRepo(); + wrapper(() => updateIndex({ gitRoot, files: files }), false); + }); + +program + .command('status') + .description('Show the working tree status') + .action(() => { + const gitRoot = ensureGitRepo(); + wrapper(() => status(gitRoot)); + }); + +program + .command('write-tree') + .description('Create a tree object from the current index') + .action(() => { + const gitRoot = ensureGitRepo(); + wrapper(() => writeTree(gitRoot)); + }); + +program + .command('commit-tree') + .description('Create a new commit object') + .argument('', 'An existing tree object.') + .option('-m ', 'A paragraph in the commit log message') + .option('-p ', 'List of parent objects') + .action((tree, { m, p }) => { + const gitRoot = ensureGitRepo(); + wrapper(() => + commitTree({ + gitRoot, + treeHash: tree, + message: m, + parents: p, + stdin: process.stdin + }) + ); + }); + +program + .command('commit') + .description('Record changes to the repository') + .argument('', 'Use the given as the commit message.') + .action((message) => { + const gitRoot = ensureGitRepo(); + wrapper(() => commit(gitRoot, message)); + }); + +program + .command('diff') + .description('Show changes between index and working tree') + .action(() => { + const gitRoot = ensureGitRepo(); + wrapper(() => gitDiff(gitRoot), false); + }); + +program.parse(process.argv); diff --git a/src/26/indexParser.ts b/src/26/indexParser.ts new file mode 100644 index 0000000..3cf755c --- /dev/null +++ b/src/26/indexParser.ts @@ -0,0 +1,194 @@ +// The format of the index file is documented at https://github.com/git/git/blob/867b1c1bf68363bcfd17667d6d4b9031fa6a1300/Documentation/technical/index-format.txt + +import fs from 'fs'; +import { + CTIME_NANO_OFFSET, + CTIME_OFFSET, + DEV_OFFSET, + FILES_SIZE_OFFSET, + FLAGS_OFFSET, + GID_OFFSET, + HASH_OFFSET, + INO_OFFSET, + LF, + MODE_OFFSET, + MTIME_NANO_OFFSET, + MTIME_OFFSET, + NAME_OFFSET, + NULL, + RELATIVE_PATH_TO_INDEX_FILE, + SPACE, + UID_OFFSET +} from './constants'; +import { Index, IndexEntry, IndexHeader } from './objects'; +import { CachedTree, CachedTreeEntry } from './objects/cachedTree'; +import path from 'path'; + +export default class IndexParser { + /** + * The current index in the buffer. + * + * @private + * @type {number} + */ + private pos: number; + + /** + * Stores the data from the .git/index file. + * + * @private + * @type {Buffer} + */ + private buf: Buffer; + + constructor(gitRoot: string) { + this.pos = 0; + // Make sure the index file is present + if (!fs.existsSync(path.join(gitRoot, RELATIVE_PATH_TO_INDEX_FILE))) { + throw new Error('Not a git repo'); + } + this.buf = fs.readFileSync(path.join(gitRoot, RELATIVE_PATH_TO_INDEX_FILE)); + } + + private parseHeader(): IndexHeader { + this.pos += 8; + return { + signature: this.buf.subarray(0, 4).toString(), + version: this.buf.readInt32BE(4) + }; + } + + private parseEntry(): IndexEntry { + const entry = {} as IndexEntry; + + entry.ctimeSec = this.buf.readInt32BE(this.pos + CTIME_OFFSET); + entry.ctimeNanoFrac = this.buf.readInt32BE(this.pos + CTIME_NANO_OFFSET); + + entry.mtimeSec = this.buf.readInt32BE(this.pos + MTIME_OFFSET); + entry.mtimeNanoFrac = this.buf.readInt32BE(this.pos + MTIME_NANO_OFFSET); + + entry.dev = this.buf.readInt32BE(this.pos + DEV_OFFSET); + + entry.ino = this.buf.readInt32BE(this.pos + INO_OFFSET); + + entry.mode = this.buf.readInt32BE(this.pos + MODE_OFFSET); + + entry.uid = this.buf.readInt32BE(this.pos + UID_OFFSET); + + entry.gid = this.buf.readInt32BE(this.pos + GID_OFFSET); + + entry.size = this.buf.readInt32BE(this.pos + FILES_SIZE_OFFSET); + + entry.hash = this.buf + .subarray(this.pos + HASH_OFFSET, this.pos + HASH_OFFSET + 20) + .toString('hex'); + + const flags = this.buf.readInt16BE(this.pos + FLAGS_OFFSET); + entry.stage = (flags & (0b11 << 12)) >> 12; + + const nameLength = 0xfff & flags; + entry.name = this.buf + .subarray(this.pos + NAME_OFFSET, this.pos + NAME_OFFSET + nameLength) + .toString('ascii'); + + this.pos += NAME_OFFSET + nameLength; + while (this.buf[this.pos] === NULL && this.pos < this.buf.length) { + this.pos++; + } + + return entry; + } + + private parseTreeEntry(): CachedTreeEntry { + const entry = {} as CachedTreeEntry; + + const nameStartPos = this.pos; + while (this.pos < this.buf.length && this.buf[this.pos] !== NULL) { + this.pos++; + } + entry.name = this.buf.subarray(nameStartPos, this.pos).toString('ascii'); + this.pos++; + + const entryCountStartPos = this.pos; + while (this.pos < this.buf.length && this.buf[this.pos] !== SPACE) { + this.pos++; + } + entry.entryCount = parseInt( + this.buf.subarray(entryCountStartPos, this.pos).toString('ascii') + ); + this.pos++; + + const subTreeCountStartPos = this.pos; + while (this.pos < this.buf.length && this.buf[this.pos] !== LF) { + this.pos++; + } + entry.subTreeCount = parseInt( + this.buf.subarray(subTreeCountStartPos, this.pos).toString('ascii') + ); + this.pos++; + + if (entry.entryCount >= 0) { + entry.hash = this.buf.subarray(this.pos, this.pos + 20).toString('hex'); + this.pos += 20; + } + + return entry; + } + + private parseTreeExtension(size: number): CachedTree { + const entries: CachedTreeEntry[] = []; + const endPos = this.pos + size; + + while (this.pos < endPos) { + const entry = this.parseTreeEntry(); + // console.log(entry); + entries.push(entry); + } + + return new CachedTree(entries); + } + + private parseExtension(): CachedTree | undefined { + const signature = this.buf.subarray(this.pos, this.pos + 4).toString(); + this.pos += 4; + + const size = this.buf.readInt32BE(this.pos); + this.pos += 4; + + // Only TREE extensions are currently supported + if (signature !== 'TREE') { + this.pos += size; + return undefined; + } + + return this.parseTreeExtension(size); + } + + /** + * Parses the .git/index file and returns an instance of the Index class. + * + * @returns {Index} + */ + parse(): Index { + const header = this.parseHeader(); + + const entriesCount = this.buf.readInt32BE(this.pos); + this.pos += 4; + + const entries = new Array(entriesCount); + + for (let i = 0; i < entriesCount; i++) { + const entry = this.parseEntry(); + // console.log(entry); + entries[i] = entry; + } + + // If no extensions present. + // The minus twenty is to check if we have reached the starting the hash. + if (this.pos === this.buf.byteLength - 20) { + return new Index(header, entries); + } + + return new Index(header, entries, this.parseExtension()); + } +} diff --git a/src/26/jestHelpers.ts b/src/26/jestHelpers.ts new file mode 100644 index 0000000..529916e --- /dev/null +++ b/src/26/jestHelpers.ts @@ -0,0 +1,53 @@ +import { randomBytes } from 'crypto'; +import path from 'path'; +import fs from 'fs'; +import os from 'os'; +import init from './commands/init'; +import * as utils from './utils'; +import { Signature } from './objects/signature'; + +const root = process.cwd(); + +/** + * Creates a temp directory to be used as git repo. + * The cwd of the process is also changed to the temp directory. + * + * @export + * @returns {string} + */ +export function createTempGitRepo(): string { + const gitRoot = path.join(os.tmpdir(), randomBytes(2).toString('hex')); + + beforeAll(() => { + // Create a new temp dir and change the cwd of the process. + fs.mkdirSync(gitRoot); + process.chdir(gitRoot); + init(); + }); + + afterAll(() => { + // Move the process back to root before cleanup to prevent ENOENT error. + process.chdir(root); + fs.rmSync(gitRoot, { recursive: true, force: true }); + }); + + return gitRoot; +} + +export function createDummyFile(): { + text: string; + filePath: string; + expectedHash: string; +} { + const text = 'what is up, doc?'; + const filePath = './temp.txt'; + fs.writeFileSync(filePath, text); + const expectedHash = 'bd9dbf5aae1a3862dd1526723246b20206e5fc37'; + return { text, filePath, expectedHash }; +} + +export function mockGetSignature() { + jest.spyOn(utils, 'getSignature').mockImplementation(() => { + return new Signature('John Doe', 'example@gmail.com'); + }); +} diff --git a/src/26/objects/cachedTree.ts b/src/26/objects/cachedTree.ts new file mode 100644 index 0000000..cc3e060 --- /dev/null +++ b/src/26/objects/cachedTree.ts @@ -0,0 +1,122 @@ +/** + * This class corresponds to the cache Extension present in the .git/index file. + * + * @export + * @class CachedTree + */ +export class CachedTree { + entries: CachedTreeEntry[]; + + constructor(entries: CachedTreeEntry[] = []) { + this.entries = entries; + } + + /** + * Add a CachedTreeEntry to this tree. + * + * @param {CachedTreeEntry} entry + */ + add(entry: CachedTreeEntry) { + this.entries.push(entry); + } + + /** + * Remove and return the CachedTreeEntry corresponding to given path. + * If not present, returns undefined. + * + * @param {string} path + * @returns {(CachedTreeEntry | undefined)} + */ + remove(path: string): CachedTreeEntry | undefined { + for (let i = 0; i < this.entries.length; i++) { + if (this.entries[i].name === path) { + const deletedElem = this.entries.splice(i, 1); + return deletedElem[0]; + } + } + return undefined; + } + + /** + * Finds the CachedTreeEntry corresponding to the given path. + * Returns undefined if not found. + * + * @param {string} path + * @returns {(CachedTreeEntry | undefined)} + */ + getEntry(path: string): CachedTreeEntry | undefined { + for (let i = 0; i < this.entries.length; i++) { + if (this.entries[i].name === path) { + return this.entries[i]; + } + } + return undefined; + } + + /** + * Encodes the given CachedTree. + * + * @returns {Buffer} + */ + encode(): Buffer { + const entryBuffers: Buffer[] = []; + let dataLength = 0; + + this.entries.forEach((entry) => { + const entryBuffer = encodeCachedTreeEntry(entry); + dataLength += entryBuffer.byteLength; + entryBuffers.push(entryBuffer); + }); + + const header = Buffer.alloc(8); + header.set(Buffer.from('TREE'), 0); + header.writeInt32BE(dataLength, 4); + + return Buffer.concat([header, ...entryBuffers]); + } +} + +export interface CachedTreeEntry { + /** + * The path of the DIR from the gitRoot. + * + * @type {string} + */ + name: string; + + /** + * Number of entries in the index that is covered by the + * tree this node represents. + * + * @type {number} + */ + entryCount: number; + + /** + * Number of subtrees this tree has. + * + * @type {number} + */ + subTreeCount: number; + + /** + * The object hash corresponding to the Tree. + * + * @type {string} + */ + hash: string; +} + +/** + * Encodes the given CachedTreeEntry + * + * @export + * @param {CachedTreeEntry} e + * @returns {Buffer} + */ +export function encodeCachedTreeEntry(e: CachedTreeEntry): Buffer { + const prefix = Buffer.from(`${e.name}\0${e.entryCount} ${e.subTreeCount}\n`); + const hash = Buffer.from(e.hash, 'hex'); + + return Buffer.concat([prefix, hash]); +} diff --git a/src/26/objects/commit.ts b/src/26/objects/commit.ts new file mode 100644 index 0000000..72e6026 --- /dev/null +++ b/src/26/objects/commit.ts @@ -0,0 +1,127 @@ +import { LF } from '../constants'; +import { parseObject } from '../utils'; +import { Signature, decodeSignature } from './signature'; + +export interface CommitArgs { + author: Signature; + committer: Signature; + message: string; + treeHash: string; + parentHashes: string[]; +} + +export class Commit { + author: Signature; + committer: Signature; + message: string; + treeHash: string; + parentHashes: string[]; + + constructor({ + author, + committer, + message, + treeHash, + parentHashes = [] + }: CommitArgs) { + this.author = author; + this.committer = committer; + this.message = message; + this.treeHash = treeHash; + this.parentHashes = parentHashes; + } + + /** + * Encodes the Commit object for storage purposes. + * + * @returns {Buffer} + */ + encode(): Buffer { + let content = `tree ${this.treeHash}\n`; + + this.parentHashes.forEach((hash) => { + content += `parent ${hash}\n`; + }); + + content += `author ${this.author.toString()}`; + + content += `\ncommitter ${this.committer.toString()}`; + + // TODO: Check if the suffix '\n' is required or not. + content += `\n\n${this.message}\n`; + + const contentBuffer = Buffer.from(content); + const header = Buffer.from(`commit ${contentBuffer.byteLength}\0`); + + return Buffer.concat([header, contentBuffer]); + } +} + +/** + * This function returns a Commit object given the hash of the object. + * + * @export + * @param {string} gitRoot + * @param {string} commitHash + * @returns {Commit} + */ +export function decodeCommit(gitRoot: string, commitHash: string): Commit { + const gitObject = parseObject(gitRoot, commitHash); + const parents: string[] = []; + const commit = {} as Commit; + + // Ensure the given hash corresponds to a commit object only + if (gitObject.type !== 'commit') { + throw new Error('The given object is not a commit object'); + } + + const data = gitObject.data; + let i = 0; + + // Parse the data + for (i = 0; i < data.length; ) { + const lineStartPos = i; + while (data[i] !== LF) { + i++; + } + + const line = data.subarray(lineStartPos, i).toString().trim(); + i++; // Skip the New line Char + + const split = line.split(' '); + if (line.length === 0) { + // Message reached + break; + } + + switch (split[0]) { + case 'tree': + // "tree " + commit.treeHash = split[1]; + break; + case 'parent': + // "parent " + parents.push(split[1]); + break; + case 'author': + // "author " + commit.author = decodeSignature( + line.substring(split[0].length + 1, line.length) + ); + break; + case 'committer': + // "committer " + commit.committer = decodeSignature( + line.substring(split[0].length + 1, line.length) + ); + break; + default: + throw new Error(`Invalid character ${split[0]} found at ${i}`); + } + } + + commit.parentHashes = parents; + commit.message = data.subarray(i).toString().trim(); + + return commit; +} diff --git a/src/26/objects/index.ts b/src/26/objects/index.ts new file mode 100644 index 0000000..ec17229 --- /dev/null +++ b/src/26/objects/index.ts @@ -0,0 +1,234 @@ +// https://github.com/git/git/blob/867b1c1bf68363bcfd17667d6d4b9031fa6a1300/Documentation/technical/index-format.txt#L38 + +import path from 'path'; +import { FileMode, Stage } from '../enums'; +import { CachedTree } from './cachedTree'; +import fs from 'fs'; +import hashObject from '../commands/hashObject'; +import { + PREFIX_SIZE, + CTIME_OFFSET, + CTIME_NANO_OFFSET, + MTIME_OFFSET, + MTIME_NANO_OFFSET, + DEV_OFFSET, + INO_OFFSET, + MODE_OFFSET, + UID_OFFSET, + GID_OFFSET, + FILES_SIZE_OFFSET, + HASH_OFFSET, + FLAGS_OFFSET, + RELATIVE_PATH_TO_INDEX_FILE +} from '../constants'; +import { createHash } from 'crypto'; + +export interface IndexEntry { + ctimeSec: number; + ctimeNanoFrac: number; + mtimeSec: number; + mtimeNanoFrac: number; + dev: number; + ino: number; + mode: FileMode; + uid: number; + gid: number; + size: number; + hash: string; + name: string; + stage: Stage; + skipWorkTree: boolean; + intentToAdd: boolean; +} + +/** + * Given a file, this function creates an IndexEntry. + * It stores the object to .git/objects using the hashObject function. + * + * @export + * @param {string} gitRoot + * @param {string} file + * @returns {IndexEntry} + */ +export function createIndexEntry(gitRoot: string, file: string): IndexEntry { + const stat = fs.lstatSync(file); + + const ctimeSec = Math.floor(stat.ctimeMs / 1000); + + const ctimeNanoFrac = Math.floor((stat.ctimeMs - ctimeSec * 1000) * 1000_000); + + const mtimeSec = Math.floor(stat.mtimeMs / 1000); + + const mtimeNanoFrac = Math.floor((stat.mtimeMs - mtimeSec * 1000) * 1000_000); + + const filePath = path.relative(gitRoot, file); + + return { + ctimeSec, + ctimeNanoFrac, + mtimeSec, + mtimeNanoFrac, + dev: stat.dev, + ino: stat.ino, + mode: FileMode.REGULAR, + uid: stat.uid, + gid: stat.gid, + size: stat.size, + hash: hashObject({ gitRoot, file: path.join(gitRoot, file), write: true }), + name: filePath, + stage: Stage.ZERO, + skipWorkTree: false, + intentToAdd: false + }; +} + +export function encodeIndexEntry(e: IndexEntry): Buffer { + const prefix = Buffer.alloc(PREFIX_SIZE, 0); + + prefix.writeUInt32BE(e.ctimeSec, CTIME_OFFSET); + prefix.writeUInt32BE(e.ctimeNanoFrac, CTIME_NANO_OFFSET); + + prefix.writeUInt32BE(e.mtimeSec, MTIME_OFFSET); + prefix.writeUInt32BE(e.mtimeNanoFrac, MTIME_NANO_OFFSET); + + prefix.writeUInt32BE(e.dev, DEV_OFFSET); + + prefix.writeUInt32BE(e.ino, INO_OFFSET); + + prefix.writeUInt32BE(e.mode, MODE_OFFSET); + + prefix.writeUInt32BE(e.uid, UID_OFFSET); + + prefix.writeUInt32BE(e.gid, GID_OFFSET); + + prefix.writeUInt32BE(e.size, FILES_SIZE_OFFSET); + + prefix.set(Buffer.from(e.hash, 'hex'), HASH_OFFSET); + + const nameLength = e.name.length < 0xfff ? e.name.length : 0xfff; + prefix.writeUInt16BE((e.stage << 12) | nameLength, FLAGS_OFFSET); + + const pathName = Buffer.from(e.name, 'ascii'); // variable + + // Ensure padding size is in between 1 - 8 + const paddingSize = 8 - ((PREFIX_SIZE + pathName.byteLength) % 8); + + const padding = Buffer.alloc(paddingSize, '\0'); + + return Buffer.concat([prefix, pathName, padding]); +} + +export interface IndexHeader { + signature: string; + version: number; +} + +export class Index { + /** + * Header information + * + * @type {IndexHeader} + */ + header: IndexHeader; + + /** + * Entries corresponding to the index. + * + * @type {IndexEntry[]} + */ + entries: IndexEntry[]; + + /** + * The CachedTree Extension + * + * @type {?CachedTree} + */ + cache?: CachedTree; + + constructor(header: IndexHeader, entries: IndexEntry[], cache?: CachedTree) { + this.header = header; + this.entries = entries; + this.cache = cache; + } + + /** + * Adds a given IndexEntry to the list of entries. + * + * @param {IndexEntry} e + */ + add(e: IndexEntry) { + this.entries.push(e); + } + + /** + * Finds the IndexEntry corresponding to the given path. + * Returns undefined if not present. + * + * @param {string} path + * @returns {(IndexEntry | undefined)} + */ + getEntry(path: string): IndexEntry | undefined { + for (let i = 0; i < this.entries.length; i++) { + if (this.entries[i].name === path) { + return this.entries[i]; + } + } + return undefined; + } + + /** + * Removes and returns the IndexEntry corresponding to the given path. + * Returns undefined if entry is not found. + * + * + * @param {string} path + * @returns {(IndexEntry | undefined)} + */ + remove(path: string): IndexEntry | undefined { + for (let i = 0; i < this.entries.length; i++) { + if (this.entries[i].name === path) { + const deletedElem = this.entries.splice(i, 1); + return deletedElem[0]; + } + } + return undefined; + } + + /** + * Saves the index to the disk. + */ + saveToDisk(): void { + // Create header. + const header = Buffer.alloc(12); + header.set(Buffer.from(this.header.signature), 0); + header.writeInt32BE(this.header.version, 4); + header.writeInt32BE(this.entries.length, 8); + + // Create buffer for index entries. + const entryBuffers: Buffer[] = []; + this.entries.forEach((entry) => { + entryBuffers.push(encodeIndexEntry(entry)); + }); + + // Create buffer for CacheTree if present. + let cache = Buffer.concat([]); + if (this.cache) { + cache = this.cache.encode(); + } + + // Final index content + const indexContent = Buffer.concat([header, ...entryBuffers, cache]); + + // Create checksum. + const checksum = Buffer.from( + createHash('sha1').update(indexContent).digest('hex'), + 'hex' + ); + + fs.writeFileSync( + RELATIVE_PATH_TO_INDEX_FILE, + Buffer.concat([indexContent, checksum]), + 'hex' + ); + } +} diff --git a/src/26/objects/signature.ts b/src/26/objects/signature.ts new file mode 100644 index 0000000..5907f34 --- /dev/null +++ b/src/26/objects/signature.ts @@ -0,0 +1,77 @@ +/** + * Signature is used to identify who and when created a commit . + * + * @export + * @class Signature + */ +export class Signature { + name: string; + email: string; + timestamp: Date; + + constructor(name: string, email: string, timestamp?: Date) { + this.name = name; + this.email = email; + this.timestamp = timestamp ?? new Date(); + } + + toString(): string { + return `${this.name} <${this.email}> ${getTimeAndTimeZone(this.timestamp)}`; + } +} + +/** + * Parses a line from commit object into a Signature instance. + * + * @export + * @param {string} line + * @returns {Signature} + */ +export function decodeSignature(line: string): Signature { + let i = 0; + + // Name is present till an opening bracket is found. + while (line[i] !== '<') { + i++; + } + const name = line.substring(0, i).trim(); + + i++; // Skip the opening bracket + + const emailStartPos = i; + while (line[i] !== '>') { + i++; + } + const email = line.substring(emailStartPos, i); + + i += 2; // Skip the closing bracket and a space + + // This split is of following the format: " " + const split = line.substring(i, line.length).trim().split(' '); + const ms = parseInt(split[0]) * 1000; + const timestamp = new Date(); + timestamp.setTime(ms); + + // TODO: Check if we need to handle the offset. + + return new Signature(name, email, timestamp); +} + +/** + * Converts a given Date object's timezone to `(+/-)hhmm` format. + * View this for the sign calculation: https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Global_Objects/Date/getTimezoneOffset#negative_values_and_positive_values + * @export + * @param {Date} date + * @returns {string} + */ + +export function getTimeAndTimeZone(date: Date): string { + const seconds = Math.floor(date.getTime() / 1000); + const timezoneOffsetInMin = date.getTimezoneOffset(); + const sign = timezoneOffsetInMin < 0 ? '+' : '-'; + const hours = Math.floor(Math.abs(timezoneOffsetInMin) / 60); + const minutes = Math.abs(timezoneOffsetInMin) - 60 * hours; + return `${seconds} ${sign}${hours.toString().padStart(2, '0')}${minutes + .toString() + .padStart(2, '0')}`; +} diff --git a/src/26/objects/tree.ts b/src/26/objects/tree.ts new file mode 100644 index 0000000..a2bf6c4 --- /dev/null +++ b/src/26/objects/tree.ts @@ -0,0 +1,335 @@ +import { createHash } from 'crypto'; +import { FileMode } from '../enums'; +import { fileType, parseObject } from '../utils'; +import zlib from 'zlib'; +import fs from 'fs'; +import path from 'path'; +import { NULL, RELATIVE_PATH_TO_OBJECT_DIR, SPACE } from '../constants'; +import { CachedTree, CachedTreeEntry } from './cachedTree'; +import { Index } from './index'; +import { Stack } from '../../utils/stack'; + +export class Tree { + /** + * The root of this tree. + * + * @type {TreeNode} + */ + root: TreeNode; + + /** + * A map with key = path to file and value = TreeNode object of a file. + * This is used to iterate over the files present in tree and to fetch nodes. + * + * @type {Map} + */ + map: Map; + + constructor() { + this.root = new TreeNode('', '', FileMode.DIR); + this.map = new Map(); + } + + /** + * Given an Index class instance, + * this function iterates over the IndexEntries and inserts it into the tree. + * + * @param {Index} index + */ + build(index: Index) { + index.entries.forEach((e) => { + const newNode = new TreeNode( + e.name, + path.basename(e.name), + FileMode.REGULAR, + e.hash + ); + this.insert(newNode); + }); + } + + /** + * Insert the given TreeNode into the tree. + * + * @param {TreeNode} node + */ + insert(node: TreeNode) { + const names = node.path.split('/'); + let tempRoot = this.root; + let pathTillNow = ''; + + let i = 0; + + // Add TreeNode for each DIR if not present. + // last entry of the names list represent the name of the file. + for (i = 0; i < names.length - 1; i++) { + const name = names[i]; + + pathTillNow = path.join(pathTillNow, name); + + if (tempRoot.children.get(name) === undefined) { + const newNode = new TreeNode(pathTillNow, name, FileMode.DIR); + tempRoot.children.set(name, newNode); + + // We are adding a new tree under the tempRoot + tempRoot.subTreeCount++; + } + if (node.mode === FileMode.REGULAR) { + // When we are adding a file under this tempRoot + tempRoot.entryCount++; + } else { + // When we are adding a DIR under this tempRoot + tempRoot.entryCount += node.entryCount; + } + + // Move down towards the leaf. + tempRoot = tempRoot.children.get(name)!; + } + + // If the new node is the root itself, then reassign the root. + if (node.path === this.root.path) { + this.root = node; + return; + } + + // Finally add the file or dir + tempRoot.children.set(names[i], node); + + if (node.mode === FileMode.REGULAR) { + tempRoot.entryCount++; + + // Store the node in the map for fast retrieval. + this.map.set(node.path, node); + } else { + tempRoot.entryCount += node.entryCount; + tempRoot.subTreeCount += 1; + } + } + + getNode(pathToFile: string): TreeNode | undefined { + return this.map.get(pathToFile); + } +} + +export class TreeNode { + /** + * The relative path to this file or dir in the working tree from the gitRoot. + * + * @type {string} + */ + path: string; + + /** + * The name of the TreeNode. + * For files it is the filename. + * For directories it is the name of the directory. + * + * @type {string} + */ + name: string; + + /** + * For regular files it is FileMode.REGULAR. + * For directories it is FileMode.DIR. + * + * @type {FileMode} + */ + mode: FileMode; + + /** + * The map of children that this Node has. + * The key is the name (NOT path) of the Node. + * + * @type {Map} + */ + children: Map; + + /** + * Hash of the TreeNode. + * For file it is passed while creating a node. + * For directories it is undefined at first, + * and then calculated later using the calculateHash function + * + * @type {?string} + */ + hash?: string; + + /** + * Number of entries in the index that is covered by the + * tree this node represents. + * For a file (leaf node) it is always zero. + * + * @type {number} + */ + entryCount: number; + + /** + * Number of subtrees this tree has. + * For a file (leaf node) it is always zero. + * + * @type {number} + */ + subTreeCount: number; + + constructor(path: string, name: string, mode: FileMode, hash?: string) { + this.path = path; + this.hash = hash; + this.name = name; + this.mode = mode; + this.children = new Map(); + this.entryCount = 0; + this.subTreeCount = 0; + + // Make sure a hash is provided when creating a TreeNode for file + if (mode === FileMode.REGULAR && hash === undefined) { + throw new Error(`No hash provided with file ${path}`); + } + } + + /** + * Calculates the hash of the root and optionally save the tree to the disk. + * All the Trees are also pushed to the provided CachedTee class. + * The entries of CachedTree are NOT sorted by the path of the files. + * + * @param {string} gitRoot + * @param {boolean} [writeToDisk=false] + * @param {CachedTree} cachedTree + * @returns {string} + */ + calculateHash( + gitRoot: string, + writeToDisk: boolean = false, + cachedTree: CachedTree + ): string { + // If this is a file + if (this.mode === FileMode.REGULAR) { + return this.hash!; + } + + const buffers: Buffer[] = []; + + // Add entry for each children. + // If the child is a DIR, calculate the hash by recursive function call. + this.children.forEach((node) => { + const hash = node.calculateHash(gitRoot, writeToDisk, cachedTree); + buffers.push( + Buffer.concat([ + Buffer.from(`${node.mode.toString(8)} ${node.name}\0`), + Buffer.from(hash, 'hex') + ]) + ); + }); + + // Prepare the object to be stored + const content = Buffer.concat(buffers); + const header = Buffer.from( + `${fileType.get(this.mode)} ${content.byteLength}\0` + ); + const store = Buffer.concat([header, content]); + + const hash = createHash('sha1').update(store).digest('hex'); + + // Create a CachedTreeEntry and add ito the provided CachedTree + const cachedTreeEntry: CachedTreeEntry = { + name: this.name, + hash: hash, + subTreeCount: this.subTreeCount, + entryCount: this.entryCount + }; + cachedTree.add(cachedTreeEntry); + + if (writeToDisk) { + const zlibContent = zlib.deflateSync(store); + const pathToBlob = path.join( + gitRoot, + RELATIVE_PATH_TO_OBJECT_DIR, + hash.substring(0, 2), + hash.substring(2, hash.length) + ); + fs.mkdirSync(path.dirname(pathToBlob), { recursive: true }); + fs.writeFileSync(pathToBlob, zlibContent, { encoding: 'hex' }); + } + return hash; + } +} + +/** + * This function decodes a tree referenced by a hash. + * + * @export + * @param {string} gitRoot + * @param {string} treeHash + * @returns {Tree} + */ +export function decodeTree(gitRoot: string, treeHash: string): Tree { + // Check if the given hash is a valid tree object + const gitObject = parseObject(gitRoot, treeHash); + if (gitObject.type !== 'tree') { + throw new Error('The given object is not a tree object'); + } + + const tree = new Tree(); + + // The Stack contains the TreeNode corresponding to directories. + const stack: Stack = new Stack(); + const newRoot = new TreeNode('', '', FileMode.DIR, treeHash); + tree.root = newRoot; + stack.push(newRoot); + + while (stack.size() > 0) { + // Pop the TreeNode from the stack. This is actually a directory + const node = stack.pop()!; + + // Get the corresponding object from the storage + const gitObject = parseObject(gitRoot, node.hash!); + const data = gitObject.data; + let i = 0; + + for (i = 0; i < data.length; ) { + // Format of each entry: + // + const modeStartPos = i; + while (data[i] !== SPACE) { + i++; + } + const mode = parseInt(data.subarray(modeStartPos, i).toString(), 8); + i++; + + const nameStartPos = i; + while (data[i] !== NULL) { + i++; + } + const name = data.subarray(nameStartPos, i).toString(); + i++; + + const hash = data.subarray(i, i + 20).toString('hex'); + i += 20; + + if (mode === FileMode.DIR) { + // If a DIR is found, create a new Node and push into the stack. + // It will be processed later on. + const newNode = new TreeNode( + path.join(node.path, name), + name, + mode, + hash + ); + stack.push(newNode); + + // Insert the dir into tree + tree.insert(newNode); + } else { + // Found a file. Insert into the tree. + const newNode = new TreeNode( + path.join(node.path, name), + name, + mode, + hash + ); + tree.insert(newNode); + } + } + } + + return tree; +} diff --git a/src/26/types.ts b/src/26/types.ts new file mode 100644 index 0000000..efdc5c0 --- /dev/null +++ b/src/26/types.ts @@ -0,0 +1,70 @@ +import fs from 'fs'; +import { FileStatusCode } from './enums'; + +/** + * Type of files stored in the .git/objects store. + * + * @export + */ +export type GitObjectType = 'blob' | 'commit' | 'tree' | 'tag'; + +export interface GitObject { + type: GitObjectType; + length: number; + data: Buffer; +} + +export interface FileStats { + /** + * Information about a file. + * + * @type {fs.Stats} + */ + stat: fs.Stats; + + /** + * Path from the root of the current git repo. + * + * @type {string} + */ + pathFromGitRoot: string; +} + +export interface FileStatus { + /** + * Path from the gitRoot to the file. + * + * @type {string} + */ + name: string; + + /** + * Status of the file in the staging area. + * + * @type {FileStatusCode} + */ + staging: FileStatusCode; + + /** + * Status of the file in the Work Tree. + * + * @type {FileStatusCode} + */ + worktree: FileStatusCode; +} + +export interface DiffEntry { + /** + * Path from the gitRoot to the file. + * + * @type {string} + */ + name: string; + + /** + * Status of the file in Staging or WorkTree. + * + * @type {FileStatusCode} + */ + status: FileStatusCode; +} diff --git a/src/26/utils.ts b/src/26/utils.ts new file mode 100644 index 0000000..1a5fd3d --- /dev/null +++ b/src/26/utils.ts @@ -0,0 +1,276 @@ +import { globSync } from 'glob'; +import { FileMode } from './enums'; +import { FileStats, GitObject, GitObjectType } from './types'; +import fs from 'fs'; +import path from 'path'; +import { + DEFAULT_IGNORE_PATTERNS, + NULL, + PATH_TO_GIT_CONFIG, + RELATIVE_PATH_TO_HEAD_FILE, + RELATIVE_PATH_TO_OBJECT_DIR, + RELATIVE_PATH_TO_REF_HEADS_DIR, + SHA1Regex, + SPACE +} from './constants'; +import zlib from 'zlib'; +import ini from 'ini'; +import { Signature } from './objects/signature'; + +export const fileModeString = new Map([ + [FileMode.EMPTY, '0'], + [FileMode.DIR, '0040000'], + [FileMode.REGULAR, '0100644'], + [FileMode.DEPRECATED, '0100664'], + [FileMode.EXECUTABLE, '0100755'], + [FileMode.SYMLINK, '0120000'], + [FileMode.SUBMODULE, '0160000'] +]); + +export const fileType = new Map([ + [FileMode.DIR, 'tree'], + [FileMode.REGULAR, 'blob'] +]); + +/** + * Returns a list of files present in `cwd`. + * It automatically includes the .gitignore file. + * + * @export + * @param {string} gitRoot + * @param {string} cwd + * @returns {string[]} + */ +export function getFiles(gitRoot: string, cwd: string): string[] { + const ignore = getIgnoredGlobPatterns(gitRoot); + return globSync('**/*', { + cwd, + nodir: true, + dot: true, + ignore + }); +} + +/** + * Get stat for all the files (ignore files included in .gitignore). + * + * @param {string} gitRoot + * @returns {Map} + */ +export function getFileStats(gitRoot: string): Map { + const ignore = getIgnoredGlobPatterns(gitRoot); + const files = globSync('**/*', { + cwd: gitRoot, + nodir: true, + dot: true, + ignore + }); + + const info = new Map(); + files.forEach((file) => { + info.set(file, { + stat: fs.lstatSync(path.join(gitRoot, file)), + pathFromGitRoot: file + }); + }); + + return info; +} + +/** + * Finds the .gitignore file from the gitRoot (if present). + * Returns an array of glob patterns that needs to be ignored. + * + * @param {string} gitRoot + * @returns {string[]} + */ +export function getIgnoredGlobPatterns(gitRoot: string): string[] { + const pathToGitIgnore = path.join(gitRoot, '.gitignore'); + if (!fs.existsSync(pathToGitIgnore)) { + return DEFAULT_IGNORE_PATTERNS; + } + const content = fs.readFileSync(pathToGitIgnore).toString(); + const ignore = content.split(/\r\n|\n/); + ignore.push(...DEFAULT_IGNORE_PATTERNS); + return ignore; +} + +/** + * Finds the current branch from the `HEAD` file. + * + * @param {string} gitRoot + * @returns {string} + */ +export function getCurrentBranchName(gitRoot: string): string { + if (!fs.existsSync(path.join(gitRoot, RELATIVE_PATH_TO_HEAD_FILE))) { + throw new Error('Invalid git repo: HEAD file is missing'); + } + const content = fs + .readFileSync(path.join(gitRoot, RELATIVE_PATH_TO_HEAD_FILE)) + .toString() + .trim(); + const contentSplit = content.split('/'); + return contentSplit[contentSplit.length - 1]; +} + +/** + * Looks up the reference to the head of the given branch name. + * Returns the contents of the reference, i.e., the hash of the commit object. + * + * @export + * @param {string} gitRoot + * @param {string} branch + * @returns {(string | undefined)} + */ +export function getBranchHeadReference( + gitRoot: string, + branch: string +): string | undefined { + const pathToRef = path.join(gitRoot, RELATIVE_PATH_TO_REF_HEADS_DIR, branch); + + if (!fs.existsSync(pathToRef)) { + return undefined; + } + + return fs.readFileSync(pathToRef).toString().trim(); +} + +/** + * Extracts user information from .gitconfig file. + * + * @export + * @returns {Signature} + */ +export function getSignature(): Signature { + const config = ini.parse(fs.readFileSync(PATH_TO_GIT_CONFIG, 'utf-8')); + if (!config.user.name || !config.user.email) { + throw new Error('No valid name or email found!'); + } + return new Signature(config.user.name, config.user.email, new Date()); +} + +/** + * Check if an object exists in the .git/objects DIR with given hash and type. + * + * @export + * @param {string} gitRoot + * @param {string} hash Hash value of the object (substring also supported). + * @param {GitObjectType} type + * @returns {string} Complete hash of the object + */ +export function verifyObject( + gitRoot: string, + hash: string, + type: GitObjectType +): string { + const subdir = hash.substring(0, 2); + const cwd = path.join(gitRoot, RELATIVE_PATH_TO_OBJECT_DIR, subdir); + + const files = globSync(`${hash.substring(2, hash.length)}*`, { + cwd, + nodir: true + }); + + if (files.length !== 1) { + throw new Error(`fatal: ${hash} is not a valid object`); + } + + const pathToFile = path.join(cwd, files[0]); + const fileContents = zlib.unzipSync(fs.readFileSync(pathToFile)); + + // Read the header + let i = 0; + for (i; i < fileContents.length; i++) { + if (fileContents[i] === NULL) { + break; + } + } + const header = fileContents.subarray(0, i).toString(); + + // Cross check the type of the object + if (header.indexOf(type) === 0) { + // Return the full hash of the object + return `${subdir}${files[0]}`; + } + + throw new Error(`fatal: ${hash} is not a valid object`); +} + +export function isValidSHA1(s: string): boolean { + return !!SHA1Regex.exec(s); +} + +/** + * This function parses header buffer from an object file and returns: + * - the type of object + * - byte length of the data + * + * @export + * @param {Buffer} buffer + * @returns {{ + type: GitObjectType; + length: number; +}} + */ +export function parseObjectHeader(buffer: Buffer): { + type: GitObjectType; + length: number; +} { + // Format of header: + // + let i = 0; + while (buffer[i] !== SPACE && i < buffer.byteLength) { + i++; + } + + const headerType = buffer.subarray(0, i).toString() as GitObjectType; + i++; + const headerLength = parseInt(buffer.subarray(i).toString()); + + return { type: headerType, length: headerLength }; +} + +/** + * Given a path to an object, this function parses it and returns: + * - the object type, + * - the byte length of the data, + * - the data + * + * Note: This function only looks up the objects stored inside .git/objects. + * Pack files are excluded from the lookup. + * + * This could cause issues since GIT performs Garbage collection (GC) + * to reduce the size of the data stored under the .git/objects dir which + * results in removal of original objects directly referenced via hash values. + * (https://git-scm.com/book/en/v2/Git-Internals-Packfiles) + * + * @export + * @param {string} gitRoot + * @param {string} hash + * @returns {GitObject} + */ +export function parseObject(gitRoot: string, hash: string): GitObject { + const pathToFile = path.join( + gitRoot, + RELATIVE_PATH_TO_OBJECT_DIR, + hash.substring(0, 2), + hash.substring(2, hash.length) + ); + + if (!isValidSHA1(hash) || !fs.existsSync(pathToFile)) { + throw new Error( + `fatal: ${hash} no such object exists.\nThe object might be present in packfile present under .git/objects/pack. This is currently not supported.` + ); + } + + // Unzip the content + const fileContents = zlib.unzipSync(fs.readFileSync(pathToFile)); + + // The header is present till we found a NULL character + let i = 0; + while (i < fileContents.length && fileContents[i] !== NULL) { + i++; + } + const header = parseObjectHeader(fileContents.subarray(0, i)); + return { ...header, data: fileContents.subarray(i + 1) }; +} diff --git a/tsconfig.json b/tsconfig.json index c4c6e1b..ea4872a 100644 --- a/tsconfig.json +++ b/tsconfig.json @@ -12,7 +12,9 @@ /* Language and Environment */ "target": "es2017" /* Set the JavaScript language version for emitted JavaScript and include compatible library declarations. */, - "lib": ["ES2021.String"], /* Specify a set of bundled library declaration files that describe the target runtime environment. */ + "lib": [ + "ES2021.String" + ] /* Specify a set of bundled library declaration files that describe the target runtime environment. */, // "jsx": "preserve", /* Specify what JSX code is generated. */ // "experimentalDecorators": true, /* Enable experimental support for legacy experimental decorators. */ // "emitDecoratorMetadata": true, /* Emit design-type metadata for decorated declarations in source files. */ @@ -25,7 +27,7 @@ // "moduleDetection": "auto", /* Control what method is used to detect module-format JS files. */ /* Modules */ - "module": "commonjs" /* Specify what module code is generated. */, + "module": "Node16" /* Specify what module code is generated. */, // "rootDir": "./", /* Specify the root folder within your source files. */ // "moduleResolution": "node10", /* Specify how TypeScript looks up a file from a given module specifier. */ // "baseUrl": "./", /* Specify the base directory to resolve non-relative module names. */ @@ -36,7 +38,7 @@ // "allowUmdGlobalAccess": true, /* Allow accessing UMD globals from modules. */ // "moduleSuffixes": [], /* List of file name suffixes to search when resolving a module. */ // "allowImportingTsExtensions": true, /* Allow imports to include TypeScript file extensions. Requires '--moduleResolution bundler' and either '--noEmit' or '--emitDeclarationOnly' to be set. */ - // "resolvePackageJsonExports": true, /* Use the package.json 'exports' field when resolving package imports. */ + "resolvePackageJsonExports": true /* Use the package.json 'exports' field when resolving package imports. */, // "resolvePackageJsonImports": true, /* Use the package.json 'imports' field when resolving imports. */ // "customConditions": [], /* Conditions to set in addition to the resolver-specific defaults when resolving imports. */ // "resolveJsonModule": true, /* Enable importing .json files. */