diff --git a/NAMING.md b/NAMING.md new file mode 100644 index 0000000..52eecd1 --- /dev/null +++ b/NAMING.md @@ -0,0 +1,16 @@ +# Naming things + +Naming things is hard, so we loosely document decisions in naming while we go. +This might go poorly. + +### Branch or branch name? + +Branches mean lots of things, for clarity, anything that is a string of a branch +name will be called a "branchName" and a branch implies an object containing +more data than that (the repo is not currently like this but will be rewritten +to adhere to it). + +### Put some respect on the name + +Capitalize Gumption in comments so it's obvious we mean Gumption, the project, +and not whimsically using the word gumption for delight. diff --git a/README.md b/README.md index 2699056..a3a24fa 100644 --- a/README.md +++ b/README.md @@ -14,5 +14,5 @@ CLI for stacked Git commands 3. Link the package to your global npm packages: ```bash - $ npm link && npm install --global gumption + $ npm link & npm install --global gumption ``` diff --git a/package-lock.json b/package-lock.json index 50bdc27..f23c5e8 100644 --- a/package-lock.json +++ b/package-lock.json @@ -13,6 +13,7 @@ "ink": "^4.1.0", "ink-select-input": "^5.0.0", "meow": "^11.0.0", + "patch-package": "^8.0.0", "react": "^18.2.0", "simple-git": "^3.24.0" }, @@ -1353,6 +1354,11 @@ "url": "https://opencollective.com/vitest" } }, + "node_modules/@yarnpkg/lockfile": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/@yarnpkg/lockfile/-/lockfile-1.1.0.tgz", + "integrity": "sha512-GpSwvyXOcOOlV70vbnzjj4fW5xW/FdUF6nQEt1ENy7m4ZCczi1+/buVUPAqmGfqznsORNFzUMjctTIp8a9tuCQ==" + }, "node_modules/acorn": { "version": "8.11.3", "resolved": "https://registry.npmjs.org/acorn/-/acorn-8.11.3.tgz", @@ -1476,6 +1482,14 @@ "node": "*" } }, + "node_modules/at-least-node": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/at-least-node/-/at-least-node-1.0.0.tgz", + "integrity": "sha512-+q/t7Ekv1EDY2l6Gda6LLiX14rU9TV20Wa3ofeQmwPFZbOMo9DXrLbOjFaaclkXKWidIaopwAObQDqwWtGUjqg==", + "engines": { + "node": ">= 4.0.0" + } + }, "node_modules/auto-bind": { "version": "5.0.1", "resolved": "https://registry.npmjs.org/auto-bind/-/auto-bind-5.0.1.tgz", @@ -1490,14 +1504,12 @@ "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/brace-expansion": { "version": "1.1.11", "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-1.1.11.tgz", "integrity": "sha512-iCuPHDFgrHX7H2vEI/5xpz07zSHB00TpugqhmYtVmMO6518mCuRMoOYFldEBl0g187ufozdaHgWKcYFb61qGiA==", - "dev": true, "dependencies": { "balanced-match": "^1.0.0", "concat-map": "0.0.1" @@ -1507,7 +1519,6 @@ "version": "3.0.2", "resolved": "https://registry.npmjs.org/braces/-/braces-3.0.2.tgz", "integrity": "sha512-b8um+L1RzM3WDSzvhm6gIz1yfTbBt6YTlcEKAvsmqCZZFw46z626lVj9j1yEPW33H5H+lBQpZMP1k8l+78Ha0A==", - "dev": true, "dependencies": { "fill-range": "^7.0.1" }, @@ -1524,6 +1535,24 @@ "node": ">=8" } }, + "node_modules/call-bind": { + "version": "1.0.7", + "resolved": "https://registry.npmjs.org/call-bind/-/call-bind-1.0.7.tgz", + "integrity": "sha512-GHTSNSYICQ7scH7sZ+M2rFopRoLh8t2bLSW6BbgrtLsahOIB5iyAVJf9GjWK3cYTDaMj4XdBpM1cA6pIS0Kv2w==", + "dependencies": { + "es-define-property": "^1.0.0", + "es-errors": "^1.3.0", + "function-bind": "^1.1.2", + "get-intrinsic": "^1.2.4", + "set-function-length": "^1.2.1" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, "node_modules/callsites": { "version": "3.1.0", "resolved": "https://registry.npmjs.org/callsites/-/callsites-3.1.0.tgz", @@ -1709,8 +1738,7 @@ "node_modules/concat-map": { "version": "0.0.1", "resolved": "https://registry.npmjs.org/concat-map/-/concat-map-0.0.1.tgz", - "integrity": "sha512-/Srv4dswyQNBfohGpz9o6Yb3Gz3SrUDqBH5rTuhGR7ahtlbYKnVxw2bCFMRljaA7EXHaXZ8wsHdodFvbkhKmqg==", - "dev": true + "integrity": "sha512-/Srv4dswyQNBfohGpz9o6Yb3Gz3SrUDqBH5rTuhGR7ahtlbYKnVxw2bCFMRljaA7EXHaXZ8wsHdodFvbkhKmqg==" }, "node_modules/confbox": { "version": "0.1.7", @@ -1736,7 +1764,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", @@ -1828,6 +1855,22 @@ "integrity": "sha512-oIPzksmTg4/MriiaYGO+okXDT7ztn/w3Eptv/+gSIdMdKsJo0u4CfYNFJPy+4SKMuCqGw2wxnA+URMg3t8a/bQ==", "dev": true }, + "node_modules/define-data-property": { + "version": "1.1.4", + "resolved": "https://registry.npmjs.org/define-data-property/-/define-data-property-1.1.4.tgz", + "integrity": "sha512-rBMvIzlpA8v6E+SJZoo++HAYqsLrkg7MSfIinMPFhmkorw7X+dOXVJQs+QT69zGkzMyfDnIMN2Wid1+NbL3T+A==", + "dependencies": { + "es-define-property": "^1.0.0", + "es-errors": "^1.3.0", + "gopd": "^1.0.1" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, "node_modules/diff": { "version": "4.0.2", "resolved": "https://registry.npmjs.org/diff/-/diff-4.0.2.tgz", @@ -1911,6 +1954,25 @@ "is-arrayish": "^0.2.1" } }, + "node_modules/es-define-property": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/es-define-property/-/es-define-property-1.0.0.tgz", + "integrity": "sha512-jxayLKShrEqqzJ0eumQbVhTYQM27CfT1T35+gCgDFoL82JLsXqTJ76zv6A0YLOgEnLUMvLzsDsGIrl8NFpT2gQ==", + "dependencies": { + "get-intrinsic": "^1.2.4" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/es-errors": { + "version": "1.3.0", + "resolved": "https://registry.npmjs.org/es-errors/-/es-errors-1.3.0.tgz", + "integrity": "sha512-Zf5H2Kxt2xjTvbJvP2ZWLEICxA6j+hAmMzIlypy4xcBg1vKVnx89Wy0GbS+kf5cwCVFFzdCFh2XSCFNULS6csw==", + "engines": { + "node": ">= 0.4" + } + }, "node_modules/esbuild": { "version": "0.20.2", "resolved": "https://registry.npmjs.org/esbuild/-/esbuild-0.20.2.tgz", @@ -2398,7 +2460,6 @@ "version": "7.0.1", "resolved": "https://registry.npmjs.org/fill-range/-/fill-range-7.0.1.tgz", "integrity": "sha512-qOo9F+dMUmC2Lcb4BbVvnKJxTPjCm+RRpe4gDuGrzkL7mEVl/djYSu2OdQ2Pa302N4oqkSg9ir6jaLWJ2USVpQ==", - "dev": true, "dependencies": { "to-regex-range": "^5.0.1" }, @@ -2422,6 +2483,14 @@ "url": "https://github.com/sponsors/sindresorhus" } }, + "node_modules/find-yarn-workspace-root": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/find-yarn-workspace-root/-/find-yarn-workspace-root-2.0.0.tgz", + "integrity": "sha512-1IMnbjt4KzsQfnhnzNd8wUEgXZ44IzZaZmnLYx7D5FZlaHt2gW20Cri8Q+E/t5tIj4+epTBub+2Zxu/vNILzqQ==", + "dependencies": { + "micromatch": "^4.0.2" + } + }, "node_modules/flat-cache": { "version": "3.2.0", "resolved": "https://registry.npmjs.org/flat-cache/-/flat-cache-3.2.0.tgz", @@ -2442,11 +2511,24 @@ "integrity": "sha512-X8cqMLLie7KsNUDSdzeN8FYK9rEt4Dt67OsG/DNGnYTSDBG4uFAJFBnUeiV+zCVAvwFy56IjM9sH51jVaEhNxw==", "dev": true }, + "node_modules/fs-extra": { + "version": "9.1.0", + "resolved": "https://registry.npmjs.org/fs-extra/-/fs-extra-9.1.0.tgz", + "integrity": "sha512-hcg3ZmepS30/7BSFqRvoo3DOMQu7IjqxO5nCDt+zM9XWjb33Wg7ziNT+Qvqbuc3+gWpzO02JubVyk2G4Zvo1OQ==", + "dependencies": { + "at-least-node": "^1.0.0", + "graceful-fs": "^4.2.0", + "jsonfile": "^6.0.1", + "universalify": "^2.0.0" + }, + "engines": { + "node": ">=10" + } + }, "node_modules/fs.realpath": { "version": "1.0.0", "resolved": "https://registry.npmjs.org/fs.realpath/-/fs.realpath-1.0.0.tgz", - "integrity": "sha512-OO0pH2lK6a0hZnAdau5ItzHPI6pUlvI7jMVnxUQRtw4owF2wk8lOSabtGDCTP4Ggrg2MbGnWO9X8K1t4+fGMDw==", - "dev": true + "integrity": "sha512-OO0pH2lK6a0hZnAdau5ItzHPI6pUlvI7jMVnxUQRtw4owF2wk8lOSabtGDCTP4Ggrg2MbGnWO9X8K1t4+fGMDw==" }, "node_modules/fsevents": { "version": "2.3.3", @@ -2479,6 +2561,24 @@ "node": "*" } }, + "node_modules/get-intrinsic": { + "version": "1.2.4", + "resolved": "https://registry.npmjs.org/get-intrinsic/-/get-intrinsic-1.2.4.tgz", + "integrity": "sha512-5uYhsJH8VJBTv7oslg4BznJYhDoRI6waYCxMmCdnTrcCrHA/fCFKoTFz2JKKE0HdDFUF7/oQuhzumXJK7paBRQ==", + "dependencies": { + "es-errors": "^1.3.0", + "function-bind": "^1.1.2", + "has-proto": "^1.0.1", + "has-symbols": "^1.0.3", + "hasown": "^2.0.0" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, "node_modules/get-stream": { "version": "8.0.1", "resolved": "https://registry.npmjs.org/get-stream/-/get-stream-8.0.1.tgz", @@ -2495,7 +2595,6 @@ "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", @@ -2558,6 +2657,22 @@ "url": "https://github.com/sponsors/sindresorhus" } }, + "node_modules/gopd": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/gopd/-/gopd-1.0.1.tgz", + "integrity": "sha512-d65bNlIadxvpb/A2abVdlqKqV563juRnZ1Wtk6s1sIR8uNsXR70xqIzVqxVf1eTqDunwT2MkczEeaezCKTZhwA==", + "dependencies": { + "get-intrinsic": "^1.1.3" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/graceful-fs": { + "version": "4.2.11", + "resolved": "https://registry.npmjs.org/graceful-fs/-/graceful-fs-4.2.11.tgz", + "integrity": "sha512-RbJ5/jmFcNNCcDV5o9eTnBLJ/HszWV0P73bc+Ff4nS/rJj+YaS6IGyiOL0VoBYX+l1Wrl3k63h/KrH+nhJ0XvQ==" + }, "node_modules/graphemer": { "version": "1.4.0", "resolved": "https://registry.npmjs.org/graphemer/-/graphemer-1.4.0.tgz", @@ -2580,6 +2695,39 @@ "node": ">=4" } }, + "node_modules/has-property-descriptors": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/has-property-descriptors/-/has-property-descriptors-1.0.2.tgz", + "integrity": "sha512-55JNKuIW+vq4Ke1BjOTjM2YctQIvCT7GFzHwmfZPGo5wnrgkid0YQtnAleFSqumZm4az3n2BS+erby5ipJdgrg==", + "dependencies": { + "es-define-property": "^1.0.0" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/has-proto": { + "version": "1.0.3", + "resolved": "https://registry.npmjs.org/has-proto/-/has-proto-1.0.3.tgz", + "integrity": "sha512-SJ1amZAJUiZS+PhsVLf5tGydlaVB8EdFpaSO4gmiUKUOxk8qzn5AIy4ZeJUmh22znIdk/uMAUT2pl3FxzVUH+Q==", + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/has-symbols": { + "version": "1.0.3", + "resolved": "https://registry.npmjs.org/has-symbols/-/has-symbols-1.0.3.tgz", + "integrity": "sha512-l3LCuF6MgDNwTDKkdYGEihYjt5pRPbEg46rtlmnSPlUbgmB8LOIrKJbYYFBSbnPaJexMKtiPO8hmeRjRz2Td+A==", + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, "node_modules/hasown": { "version": "2.0.2", "resolved": "https://registry.npmjs.org/hasown/-/hasown-2.0.2.tgz", @@ -2660,7 +2808,6 @@ "version": "1.0.6", "resolved": "https://registry.npmjs.org/inflight/-/inflight-1.0.6.tgz", "integrity": "sha512-k92I/b08q4wvFscXCLvqfsHCrjrF7yiXsQuIVvVE7N82W3+aqpzuUdBbfhWcy/FZR3/4IgflMgKLOsvPDrGCJA==", - "dev": true, "dependencies": { "once": "^1.3.0", "wrappy": "1" @@ -2669,8 +2816,7 @@ "node_modules/inherits": { "version": "2.0.4", "resolved": "https://registry.npmjs.org/inherits/-/inherits-2.0.4.tgz", - "integrity": "sha512-k/vGaX4/Yla3WzyMCvTQOXYeIHvqOKtnqBduzTHpzpQZzAskKMhZ2K+EnBiSM9zGSoIFeMpXKxa4dYeZIQqewQ==", - "dev": true + "integrity": "sha512-k/vGaX4/Yla3WzyMCvTQOXYeIHvqOKtnqBduzTHpzpQZzAskKMhZ2K+EnBiSM9zGSoIFeMpXKxa4dYeZIQqewQ==" }, "node_modules/ink": { "version": "4.4.1", @@ -2812,6 +2958,20 @@ "url": "https://github.com/sponsors/ljharb" } }, + "node_modules/is-docker": { + "version": "2.2.1", + "resolved": "https://registry.npmjs.org/is-docker/-/is-docker-2.2.1.tgz", + "integrity": "sha512-F+i2BKsFrH66iaUFc0woD8sLy8getkwTwtOBjvs56Cx4CgJDeKQeqfz8wAYiSb8JOprWhHH5p77PbmYCvvUuXQ==", + "bin": { + "is-docker": "cli.js" + }, + "engines": { + "node": ">=8" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, "node_modules/is-extglob": { "version": "2.1.1", "resolved": "https://registry.npmjs.org/is-extglob/-/is-extglob-2.1.1.tgz", @@ -2856,7 +3016,6 @@ "version": "7.0.0", "resolved": "https://registry.npmjs.org/is-number/-/is-number-7.0.0.tgz", "integrity": "sha512-41Cifkg6e8TylSpdtTpeLVMqvSBEVzTttHvERD741+pnZ8ANv0004MRL43QKPDlK9cGvNp6NZWZUBlbGXYxxng==", - "dev": true, "engines": { "node": ">=0.12.0" } @@ -2909,11 +3068,26 @@ "tslib": "^2.0.3" } }, + "node_modules/is-wsl": { + "version": "2.2.0", + "resolved": "https://registry.npmjs.org/is-wsl/-/is-wsl-2.2.0.tgz", + "integrity": "sha512-fKzAra0rGJUUBwGBgNkHZuToZcn+TtXHpeCgmkMJMMYx1sQDYaCSyjJBSCa2nH1DGm7s3n1oBnohoVTBaN7Lww==", + "dependencies": { + "is-docker": "^2.0.0" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/isarray": { + "version": "2.0.5", + "resolved": "https://registry.npmjs.org/isarray/-/isarray-2.0.5.tgz", + "integrity": "sha512-xHjhDr3cNBK0BzdUJSPXZntQUx/mwMS5Rw4A7lPJ90XGAO6ISP/ePDNuo0vhqOZU+UD5JoodwCAAoZQd3FeAKw==" + }, "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/js-tokens": { "version": "4.0.0", @@ -2949,12 +3123,48 @@ "integrity": "sha512-xbbCH5dCYU5T8LcEhhuh7HJ88HXuW3qsI3Y0zOZFKfZEHcpWiHU/Jxzk629Brsab/mMiHQti9wMP+845RPe3Vg==", "dev": true }, + "node_modules/json-stable-stringify": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/json-stable-stringify/-/json-stable-stringify-1.1.1.tgz", + "integrity": "sha512-SU/971Kt5qVQfJpyDveVhQ/vya+5hvrjClFOcr8c0Fq5aODJjMwutrOfCU+eCnVD5gpx1Q3fEqkyom77zH1iIg==", + "dependencies": { + "call-bind": "^1.0.5", + "isarray": "^2.0.5", + "jsonify": "^0.0.1", + "object-keys": "^1.1.1" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, "node_modules/json-stable-stringify-without-jsonify": { "version": "1.0.1", "resolved": "https://registry.npmjs.org/json-stable-stringify-without-jsonify/-/json-stable-stringify-without-jsonify-1.0.1.tgz", "integrity": "sha512-Bdboy+l7tA3OGW6FjyFHWkP5LuByj1Tk33Ljyq0axyzdk9//JSi2u3fP1QSmd1KNwq6VOKYGlAu87CisVir6Pw==", "dev": true }, + "node_modules/jsonfile": { + "version": "6.1.0", + "resolved": "https://registry.npmjs.org/jsonfile/-/jsonfile-6.1.0.tgz", + "integrity": "sha512-5dgndWOriYSm5cnYaJNhalLNDKOqFwyDB/rr1E9ZsGciGvKPs8R2xYGCacuf3z6K1YKDz182fd+fY3cn3pMqXQ==", + "dependencies": { + "universalify": "^2.0.0" + }, + "optionalDependencies": { + "graceful-fs": "^4.1.6" + } + }, + "node_modules/jsonify": { + "version": "0.0.1", + "resolved": "https://registry.npmjs.org/jsonify/-/jsonify-0.0.1.tgz", + "integrity": "sha512-2/Ki0GcmuqSrgFyelQq9M05y7PS0mEwuIzrf3f1fPqkVDVRvZrPZtVSMHxdgo8Aq0sxAOb/cr2aqqA3LeWHVPg==", + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, "node_modules/keyv": { "version": "4.5.4", "resolved": "https://registry.npmjs.org/keyv/-/keyv-4.5.4.tgz", @@ -2972,6 +3182,14 @@ "node": ">=0.10.0" } }, + "node_modules/klaw-sync": { + "version": "6.0.0", + "resolved": "https://registry.npmjs.org/klaw-sync/-/klaw-sync-6.0.0.tgz", + "integrity": "sha512-nIeuVSzdCCs6TDPTqI8w1Yre34sSq7AkZ4B3sfOBbI2CgVSB4Du4aLQijFU2+lhAFCwt9+42Hel6lQNIv6AntQ==", + "dependencies": { + "graceful-fs": "^4.1.11" + } + }, "node_modules/levn": { "version": "0.4.1", "resolved": "https://registry.npmjs.org/levn/-/levn-0.4.1.tgz", @@ -3146,7 +3364,6 @@ "version": "4.0.5", "resolved": "https://registry.npmjs.org/micromatch/-/micromatch-4.0.5.tgz", "integrity": "sha512-DMy+ERcEW2q8Z2Po+WNXuw3c5YaUSFjAO5GsJqfEl7UjvtIuFKO6ZrKvcItdy98dwFI2N1tg3zNIdKaQT+aNdA==", - "dev": true, "dependencies": { "braces": "^3.0.2", "picomatch": "^2.3.1" @@ -3175,7 +3392,6 @@ "version": "3.1.2", "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-3.1.2.tgz", "integrity": "sha512-J7p63hRiAjw1NDEww1W7i37+ByIrOWO5XQQAzZ3VOcL0PNybwpfmV/N05zFAzwQ9USyEcX6t3UO+K5aqBQOIHw==", - "dev": true, "dependencies": { "brace-expansion": "^1.1.7" }, @@ -3183,6 +3399,14 @@ "node": "*" } }, + "node_modules/minimist": { + "version": "1.2.8", + "resolved": "https://registry.npmjs.org/minimist/-/minimist-1.2.8.tgz", + "integrity": "sha512-2yyAR8qBkN3YuheJanUpWC5U3bb5osDywNB8RzDVlDwDHbocAJveqqj1u8+SVD7jkWT4yvsHCpWqqWqAxb0zCA==", + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, "node_modules/minimist-options": { "version": "4.1.0", "resolved": "https://registry.npmjs.org/minimist-options/-/minimist-options-4.1.0.tgz", @@ -3278,11 +3502,18 @@ "url": "https://github.com/sponsors/sindresorhus" } }, + "node_modules/object-keys": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/object-keys/-/object-keys-1.1.1.tgz", + "integrity": "sha512-NuAESUOUMrlIXOfHKzD6bpPu3tYt3xvjNdRIQ+FeT0lNb4K8WR70CaDxhuNguS2XG+GjkyMwOzsN5ZktImfhLA==", + "engines": { + "node": ">= 0.4" + } + }, "node_modules/once": { "version": "1.4.0", "resolved": "https://registry.npmjs.org/once/-/once-1.4.0.tgz", "integrity": "sha512-lNaJgI+2Q5URQBkccEKHTQOPaXdUxnZZElQTZY0MFUAuaEqe1E+Nyvgdz/aIyNi6Z9MzO5dv1H8n58/GELp3+w==", - "dev": true, "dependencies": { "wrappy": "1" } @@ -3301,6 +3532,21 @@ "url": "https://github.com/sponsors/sindresorhus" } }, + "node_modules/open": { + "version": "7.4.2", + "resolved": "https://registry.npmjs.org/open/-/open-7.4.2.tgz", + "integrity": "sha512-MVHddDVweXZF3awtlAS+6pgKLlm/JgxZ90+/NBurBoQctVOOB/zDdVjcyPzQ+0laDGbsWgrRkflI65sQeOgT9Q==", + "dependencies": { + "is-docker": "^2.0.0", + "is-wsl": "^2.1.1" + }, + "engines": { + "node": ">=8" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, "node_modules/optionator": { "version": "0.9.4", "resolved": "https://registry.npmjs.org/optionator/-/optionator-0.9.4.tgz", @@ -3318,6 +3564,14 @@ "node": ">= 0.8.0" } }, + "node_modules/os-tmpdir": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/os-tmpdir/-/os-tmpdir-1.0.2.tgz", + "integrity": "sha512-D2FR03Vir7FIu45XBY20mTb+/ZSWB00sjU9jdQXt83gDrI4Ztz5Fs7/yy74g2N5SVQY4xY1qDr4rNddwYRVX0g==", + "engines": { + "node": ">=0.10.0" + } + }, "node_modules/p-limit": { "version": "3.1.0", "resolved": "https://registry.npmjs.org/p-limit/-/p-limit-3.1.0.tgz", @@ -3385,6 +3639,119 @@ "node": "^12.20.0 || ^14.13.1 || >=16.0.0" } }, + "node_modules/patch-package": { + "version": "8.0.0", + "resolved": "https://registry.npmjs.org/patch-package/-/patch-package-8.0.0.tgz", + "integrity": "sha512-da8BVIhzjtgScwDJ2TtKsfT5JFWz1hYoBl9rUQ1f38MC2HwnEIkK8VN3dKMKcP7P7bvvgzNDbfNHtx3MsQb5vA==", + "dependencies": { + "@yarnpkg/lockfile": "^1.1.0", + "chalk": "^4.1.2", + "ci-info": "^3.7.0", + "cross-spawn": "^7.0.3", + "find-yarn-workspace-root": "^2.0.0", + "fs-extra": "^9.0.0", + "json-stable-stringify": "^1.0.2", + "klaw-sync": "^6.0.0", + "minimist": "^1.2.6", + "open": "^7.4.2", + "rimraf": "^2.6.3", + "semver": "^7.5.3", + "slash": "^2.0.0", + "tmp": "^0.0.33", + "yaml": "^2.2.2" + }, + "bin": { + "patch-package": "index.js" + }, + "engines": { + "node": ">=14", + "npm": ">5" + } + }, + "node_modules/patch-package/node_modules/ansi-styles": { + "version": "4.3.0", + "resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-4.3.0.tgz", + "integrity": "sha512-zbB9rCJAT1rbjiVDb2hqKFHNYLxgtk8NURxZ3IZwD3F6NtxbXZQCnnSi1Lkx+IDohdPlFp222wVALIheZJQSEg==", + "dependencies": { + "color-convert": "^2.0.1" + }, + "engines": { + "node": ">=8" + }, + "funding": { + "url": "https://github.com/chalk/ansi-styles?sponsor=1" + } + }, + "node_modules/patch-package/node_modules/chalk": { + "version": "4.1.2", + "resolved": "https://registry.npmjs.org/chalk/-/chalk-4.1.2.tgz", + "integrity": "sha512-oKnbhFyRIXpUuez8iBMmyEa4nbj4IOQyuhc/wy9kY7/WVPcwIO9VA668Pu8RkO7+0G76SLROeyw9CpQ061i4mA==", + "dependencies": { + "ansi-styles": "^4.1.0", + "supports-color": "^7.1.0" + }, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/chalk/chalk?sponsor=1" + } + }, + "node_modules/patch-package/node_modules/color-convert": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/color-convert/-/color-convert-2.0.1.tgz", + "integrity": "sha512-RRECPsj7iu/xb5oKYcsFHSppFNnsj/52OVTRKb4zP5onXwVF3zVmmToNcOfGC+CRDpfK/U584fMg38ZHCaElKQ==", + "dependencies": { + "color-name": "~1.1.4" + }, + "engines": { + "node": ">=7.0.0" + } + }, + "node_modules/patch-package/node_modules/color-name": { + "version": "1.1.4", + "resolved": "https://registry.npmjs.org/color-name/-/color-name-1.1.4.tgz", + "integrity": "sha512-dOy+3AuW3a2wNbZHIuMZpTcgjGuLU/uBL/ubcZF9OXbDo8ff4O8yVp5Bf0efS8uEoYo5q4Fx7dY9OgQGXgAsQA==" + }, + "node_modules/patch-package/node_modules/has-flag": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/has-flag/-/has-flag-4.0.0.tgz", + "integrity": "sha512-EykJT/Q1KjTWctppgIAgfSO0tKVuZUjhgMr17kqTumMl6Afv3EISleU7qZUzoXDFTAHTDC4NOoG/ZxU3EvlMPQ==", + "engines": { + "node": ">=8" + } + }, + "node_modules/patch-package/node_modules/rimraf": { + "version": "2.7.1", + "resolved": "https://registry.npmjs.org/rimraf/-/rimraf-2.7.1.tgz", + "integrity": "sha512-uWjbaKIK3T1OSVptzX7Nl6PvQ3qAGtKEtVRjRuazjfL3Bx5eI409VZSqgND+4UNnmzLVdPj9FqFJNPqBZFve4w==", + "deprecated": "Rimraf versions prior to v4 are no longer supported", + "dependencies": { + "glob": "^7.1.3" + }, + "bin": { + "rimraf": "bin.js" + } + }, + "node_modules/patch-package/node_modules/slash": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/slash/-/slash-2.0.0.tgz", + "integrity": "sha512-ZYKh3Wh2z1PpEXWr0MpSBZ0V6mZHAQfYevttO11c51CaWjGTaadiKZ+wVt1PbMlDV5qhMFslpZCemhwOK7C89A==", + "engines": { + "node": ">=6" + } + }, + "node_modules/patch-package/node_modules/supports-color": { + "version": "7.2.0", + "resolved": "https://registry.npmjs.org/supports-color/-/supports-color-7.2.0.tgz", + "integrity": "sha512-qpCAvRl9stuOHveKsn7HncJRvv501qIacKzQlO/+Lwxc9+0q2wLyv4Dfvt80/DPn2pqOBsJdDiogXGR9+OvwRw==", + "dependencies": { + "has-flag": "^4.0.0" + }, + "engines": { + "node": ">=8" + } + }, "node_modules/path-exists": { "version": "4.0.0", "resolved": "https://registry.npmjs.org/path-exists/-/path-exists-4.0.0.tgz", @@ -3398,7 +3765,6 @@ "version": "1.0.1", "resolved": "https://registry.npmjs.org/path-is-absolute/-/path-is-absolute-1.0.1.tgz", "integrity": "sha512-AVbw3UJ2e9bq64vSaS9Am0fje1Pa8pbGqTTsmXfaIiMpnr5DlDhfJOuLj9Sf95ZPVDAUerDfEk88MPmPe7UCQg==", - "dev": true, "engines": { "node": ">=0.10.0" } @@ -3407,7 +3773,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" } @@ -3445,7 +3810,6 @@ "version": "2.3.1", "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-2.3.1.tgz", "integrity": "sha512-JU3teHTNjmE2VCGFzuY8EXzCDVwEqB2a8fsIvwaStHhAWJEeVd1o1QD80CU6+ZdEXXSLbSsuLwJjkCBWqRQUVA==", - "dev": true, "engines": { "node": ">=8.6" }, @@ -3959,11 +4323,26 @@ "node": ">=10" } }, + "node_modules/set-function-length": { + "version": "1.2.2", + "resolved": "https://registry.npmjs.org/set-function-length/-/set-function-length-1.2.2.tgz", + "integrity": "sha512-pgRc4hJ4/sNjWCSS9AmnS40x3bNMDTknHgL5UaMBTMyJnU90EgWh1Rz+MC9eFu4BuN/UwZjKQuY/1v3rM7HMfg==", + "dependencies": { + "define-data-property": "^1.1.4", + "es-errors": "^1.3.0", + "function-bind": "^1.1.2", + "get-intrinsic": "^1.2.4", + "gopd": "^1.0.1", + "has-property-descriptors": "^1.0.2" + }, + "engines": { + "node": ">= 0.4" + } + }, "node_modules/shebang-command": { "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" }, @@ -3975,7 +4354,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" } @@ -4273,11 +4651,21 @@ "node": ">=14.0.0" } }, + "node_modules/tmp": { + "version": "0.0.33", + "resolved": "https://registry.npmjs.org/tmp/-/tmp-0.0.33.tgz", + "integrity": "sha512-jRCJlojKnZ3addtTOjdIqoRuPEKBvNXcGYqzO6zWZX8KfKEpnGY5jfggJQ3EjKuu8D4bJRr0y+cYJFmYbImXGw==", + "dependencies": { + "os-tmpdir": "~1.0.2" + }, + "engines": { + "node": ">=0.6.0" + } + }, "node_modules/to-regex-range": { "version": "5.0.1", "resolved": "https://registry.npmjs.org/to-regex-range/-/to-regex-range-5.0.1.tgz", "integrity": "sha512-65P7iz6X5yEr1cwcgvQxbbIw7Uk3gOy5dIdtZ4rDveLqhrdJP+Li/Hx6tyK0NEb+2GCyneCMJiGqrADCSNk8sQ==", - "dev": true, "dependencies": { "is-number": "^7.0.0" }, @@ -4439,6 +4827,14 @@ "integrity": "sha512-JlCMO+ehdEIKqlFxk6IfVoAUVmgz7cU7zD/h9XZ0qzeosSHmUJVOzSQvvYSYWXkFXC+IfLKSIffhv0sVZup6pA==", "dev": true }, + "node_modules/universalify": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/universalify/-/universalify-2.0.1.tgz", + "integrity": "sha512-gptHNQghINnc/vTGIk0SOFGFNXw7JVrlRUtConJRlvaw6DuX0wO5Jeko9sWrMBhh+PsYAZ7oXAiOnf/UKogyiw==", + "engines": { + "node": ">= 10.0.0" + } + }, "node_modules/uri-js": { "version": "4.4.1", "resolved": "https://registry.npmjs.org/uri-js/-/uri-js-4.4.1.tgz", @@ -4609,7 +5005,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" }, @@ -4703,8 +5098,7 @@ "node_modules/wrappy": { "version": "1.0.2", "resolved": "https://registry.npmjs.org/wrappy/-/wrappy-1.0.2.tgz", - "integrity": "sha512-l4Sp/DRseor9wL6EvV2+TuQn63dMkPjZ/sp9XkghTEbV9KlPS1xUsZ3u7/IQO4wxtcFB4bgpQPRcR3QCvezPcQ==", - "dev": true + "integrity": "sha512-l4Sp/DRseor9wL6EvV2+TuQn63dMkPjZ/sp9XkghTEbV9KlPS1xUsZ3u7/IQO4wxtcFB4bgpQPRcR3QCvezPcQ==" }, "node_modules/ws": { "version": "7.5.9", @@ -4733,6 +5127,17 @@ "resolved": "https://registry.npmjs.org/yallist/-/yallist-4.0.0.tgz", "integrity": "sha512-3wdGidZyq5PB084XLES5TpOSRA3wjXAlIWMhum2kRcv/41Sn2emQ0dycQW4uZXLejwKvg6EsvbdlVL+FYEct7A==" }, + "node_modules/yaml": { + "version": "2.6.1", + "resolved": "https://registry.npmjs.org/yaml/-/yaml-2.6.1.tgz", + "integrity": "sha512-7r0XPzioN/Q9kXBro/XPnA6kznR73DHq+GXh5ON7ZozRO6aMjbmiBuKste2wslTFkC5d1dw0GooOCepZXJ2SAg==", + "bin": { + "yaml": "bin.mjs" + }, + "engines": { + "node": ">= 14" + } + }, "node_modules/yargs-parser": { "version": "21.1.1", "resolved": "https://registry.npmjs.org/yargs-parser/-/yargs-parser-21.1.1.tgz", diff --git a/package.json b/package.json index 6155d74..2de7966 100644 --- a/package.json +++ b/package.json @@ -11,6 +11,7 @@ "node": ">=16" }, "scripts": { + "postinstall": "patch-package", "test": "vitest", "build": "tsc", "dev": "tsc --watch", @@ -25,6 +26,7 @@ "ink": "^4.1.0", "ink-select-input": "^5.0.0", "meow": "^11.0.0", + "patch-package": "^8.0.0", "react": "^18.2.0", "simple-git": "^3.24.0" }, diff --git a/patches/ink+4.4.1.patch b/patches/ink+4.4.1.patch new file mode 100644 index 0000000..fad89f1 --- /dev/null +++ b/patches/ink+4.4.1.patch @@ -0,0 +1,42 @@ +diff --git a/node_modules/ink/build/hooks/use-input.js b/node_modules/ink/build/hooks/use-input.js +index 38af918..d1764f0 100644 +--- a/node_modules/ink/build/hooks/use-input.js ++++ b/node_modules/ink/build/hooks/use-input.js +@@ -1,4 +1,4 @@ +-import { useEffect } from 'react'; ++import { useEffect, useState } from 'react'; + import { isUpperCase } from 'is-upper-case'; + import parseKeypress, { nonAlphanumericKeys } from '../parse-keypress.js'; + import reconciler from '../reconciler.js'; +@@ -30,6 +30,14 @@ import useStdin from './use-stdin.js'; + const useInput = (inputHandler, options = {}) => { + // eslint-disable-next-line @typescript-eslint/naming-convention + const { stdin, setRawMode, internal_exitOnCtrlC, internal_eventEmitter } = useStdin(); ++ ++ const [errorState, setErrorState] = useState({ hasError: false }); ++ ++ useEffect(() => { ++ if (!errorState.hasError) return; ++ throw errorState.error; ++ }, [errorState]) ++ + useEffect(() => { + if (options.isActive === false) { + return; +@@ -83,9 +91,13 @@ const useInput = (inputHandler, options = {}) => { + if (!(input === 'c' && key.ctrl) || !internal_exitOnCtrlC) { + // @ts-expect-error TypeScript types for `batchedUpdates` require an argument, but React's codebase doesn't provide it and it works without it as exepected. + reconciler.batchedUpdates(() => { +- inputHandler(input, key); +- }); +- } ++ try { ++ inputHandler(input, key); ++ } catch (e) { ++ setErrorState({ hasError: true, error: e }) ++ } ++ }); ++ } + }; + internal_eventEmitter?.on('input', handleData); + return () => { diff --git a/src/app.tsx b/src/app.tsx index ff62cdc..8a9b7c4 100644 --- a/src/app.tsx +++ b/src/app.tsx @@ -1,5 +1,6 @@ import React from 'react'; import { Box, Text } from 'ink'; +import { GumptionErrorBoundary } from './components/gumption-error-boundary.js'; import { type Result } from 'meow'; import { findCommand, getCli } from './utils/commands.js'; @@ -50,5 +51,9 @@ export default function App({ cli: _cli }: Props) { const CommandHandlerComponent = command.component; - return ; + return ( + + + + ); } diff --git a/src/commands/branch/new.test.tsx b/src/commands/branch/new.test.tsx deleted file mode 100644 index 3def03e..0000000 --- a/src/commands/branch/new.test.tsx +++ /dev/null @@ -1,121 +0,0 @@ -import BranchNew from './new.js'; -import React from 'react'; -import { Loading } from '../../components/loading.js'; -import { Text } from 'ink'; -import { delay } from '../../utils/time.js'; -import { describe, expect, it, vi } from 'vitest'; -import { render } from 'ink-testing-library'; -import { safeBranchNameFromCommitMessage } from '../../utils/naming.js'; - -const ARBITRARY_DELAY = 120; // ms - -const mocks = vi.hoisted(() => { - return { - createGitService: vi.fn(({}) => { - return { - checkout: async () => { - return new Promise((resolve) => - setTimeout(resolve, ARBITRARY_DELAY / 4) - ); - }, - currentBranch: async () => { - return new Promise((resolve) => - setTimeout(() => resolve('root'), ARBITRARY_DELAY / 4) - ); - }, - listBranches: async () => { - return new Promise((resolve) => - setTimeout( - () => resolve(['root', 'branch']), - ARBITRARY_DELAY / 4 - ) - ); - }, - createBranch: async () => { - return new Promise((resolve) => - setTimeout(resolve, ARBITRARY_DELAY / 4) - ); - }, - addAllFiles: async () => { - return new Promise((resolve) => - setTimeout(resolve, ARBITRARY_DELAY / 4) - ); - }, - commit: async ({ message }: { message: string }) => { - console.log(message); - return new Promise((resolve) => - setTimeout(resolve, ARBITRARY_DELAY / 4) - ); - }, - }; - }), - }; -}); - -vi.mock('../../services/git.js', () => { - return { - DEFAULT_OPTIONS: {}, - createGitService: mocks.createGitService, - }; -}); - -vi.mock('../../services/store.js', async () => { - const { mockStoreService } = await import('../../utils/test-helpers.js'); - return mockStoreService({ rootInitialized: true }); -}); - -describe('correctly renders changes commit UI', () => { - it('runs as intended', async () => { - const actual1 = render( - - ); - - const newBranchName = safeBranchNameFromCommitMessage('commit message'); - - const ExpectedComp = () => { - return ( - - New branch created - {newBranchName} - - ); - }; - const expected = render(); - - await delay(ARBITRARY_DELAY + 250); - expect(actual1.lastFrame()).to.equal(expected.lastFrame()); - }); - - it('displays a loading state while processing', async () => { - const actual1 = render( - - ); - - const actual2 = render( - - ); - - const expected = render(); - - await delay(ARBITRARY_DELAY / 2); - expect(actual1.lastFrame()).to.equal(expected.lastFrame()); - expect(actual2.lastFrame()).to.equal(expected.lastFrame()); - }); -}); diff --git a/src/commands/branch/new.tsx b/src/commands/branch/new.tsx index dc02e0f..f015de7 100644 --- a/src/commands/branch/new.tsx +++ b/src/commands/branch/new.tsx @@ -1,6 +1,4 @@ -import ErrorDisplay from '../../components/error-display.js'; -import React, { useCallback } from 'react'; -import { Action, useAction } from '../../hooks/use-action.js'; +import React from 'react'; import { CommandConfig, CommandProps, @@ -8,91 +6,52 @@ import { Valid, } from '../../types.js'; import { Loading } from '../../components/loading.js'; -import { SelectRootBranch } from '../../components/select-root-branch.js'; import { Text } from 'ink'; -import { UntrackedBranch } from '../../components/untracked-branch.js'; +import { + assertBranchNameExists, + assertCurrentHasDiff, +} from '../../modules/branch/assertions.js'; +import { engine } from '../../modules/engine.js'; +import { git } from '../../modules/git.js'; import { safeBranchNameFromCommitMessage } from '../../utils/naming.js'; -import { useGit } from '../../hooks/use-git.js'; -import { useTree } from '../../hooks/use-tree.js'; +import { useAction } from '../../hooks/use-action.js'; const BranchNew = (props: CommandProps) => { - const { rootBranchName, isCurrentBranchTracked } = useTree(); - - if (!rootBranchName) { - return ; - } - - if (!isCurrentBranchTracked) { - return ; - } - - return ; -}; - -const DoBranchNew = (props: CommandProps) => { const args = branchNewConfig.getProps(props) as Valid< PropSanitationResult >; const { commitMessage } = args.props; - const result = useBranchNew({ - message: commitMessage, + const result = useAction<{ newBranchName: string }>({ + func: () => { + const newBranchName = + safeBranchNameFromCommitMessage(commitMessage); + const branchBeforeName = git.getCurrentBranchName(); + assertCurrentHasDiff(); + git.createBranch({ branchName: newBranchName }); + assertBranchNameExists(newBranchName); + git.checkoutBranch(newBranchName); + git.stageAllChanges(); + git.commit({ message: commitMessage }); + + engine.trackBranch({ + branchName: newBranchName, + parentBranchName: branchBeforeName, + }); + + return { newBranchName }; + }, }); - if (result.isError) { - return ; - } - - if (result.isLoading) { - return ; - } + if (!result.isComplete) return ; return ( - New branch created - {result.branchName} + New branch created - {result.data.newBranchName} ); }; -type UseBranchNewAction = Action & { - branchName: string; -}; - -const useBranchNew = ({ message }: { message: string }): UseBranchNewAction => { - const git = useGit(); - const { attachTo } = useTree(); - - const branchName = safeBranchNameFromCommitMessage(message); - - const performGitActions = useCallback(async () => { - const branchBefore = await git.currentBranch(); - - await git.createBranch({ branchName }); - await git.checkout(branchName); - await git.addAllFiles(); - await git.commit({ message }); - - return branchBefore; - }, [branchName]); - - const performAction = useCallback(async () => { - await performGitActions().then((prevBranch) => { - attachTo({ newBranch: branchName, parent: prevBranch }); - }); - }, [branchName]); - - const action = useAction({ - asyncAction: performAction, - }); - - return { - isLoading: action.isLoading, - isError: action.isError, - error: action.error, - branchName, - } as UseBranchNewAction; -}; - interface CommandArgs { commitMessage: string; } diff --git a/src/commands/branch/track.tsx b/src/commands/branch/track.tsx index dd386eb..c469fc0 100644 --- a/src/commands/branch/track.tsx +++ b/src/commands/branch/track.tsx @@ -1,73 +1,59 @@ -import React, { useCallback, useMemo, useState } from 'react'; +import React, { useMemo, useState } from 'react'; +import { ActionState } from '../../hooks/use-action.js'; import { CommandConfig, CommandProps } from '../../types.js'; -import { Loading } from '../../components/loading.js'; import { SearchSelectInput } from '../../components/select-search-input.js'; -import { SelectRootBranch } from '../../components/select-root-branch.js'; import { Text } from 'ink'; -import { useGitHelpers } from '../../hooks/use-git-helpers.js'; -import { useTree } from '../../hooks/use-tree.js'; +import { + assertBranchIsValidOrRoot, + isBranchNotNone, +} from '../../modules/branch/assertions.js'; +import { engine } from '../../modules/engine.js'; +import { git } from '../../modules/git.js'; +import { loadBranch } from '../../modules/branch/wrapper.js'; +import { tree } from '../../modules/tree.js'; function BranchTrack(_: CommandProps) { - const { allBranches, currentBranch } = useGitHelpers(); - const { - rootBranchName, - isCurrentBranchTracked, - attachTo, - isLoading, - currentTree, - } = useTree(); + const currentBranchName = git.getCurrentBranchName(); + const currentBranch = loadBranch(currentBranchName); + const isCurrentBranchTracked = isBranchNotNone(currentBranch); - // either false or the name of the parent branch - const [complete, setComplete] = useState(false); - - const trackBranch = useCallback( - ({ parentBranch }: { parentBranch: string }) => { - if (currentBranch.isLoading) return; - attachTo({ newBranch: currentBranch.value, parent: parentBranch }); - setComplete(parentBranch); - }, - [attachTo, currentBranch.value, currentBranch.isLoading] - ); + const [state, setState] = useState< + ActionState<{ newParentBranchName: string }> + >({ + isComplete: false, + }); const branchItems = useMemo(() => { - if (allBranches.isLoading) return []; - // only branches in the tree already can be selected as the parent in this case - const branchesInTree = allBranches.value.filter((b) => { - return Boolean(currentTree.find((node) => node.key === b)); + return tree.getTree().map((branch) => { + return { + label: branch.name, + value: branch.name, + }; }); - return branchesInTree.map((b) => ({ label: b, value: b })); - }, [allBranches.value, allBranches.isLoading, currentTree]); - - if (isLoading || currentBranch.isLoading || allBranches.isLoading) { - return ; - } - - if (!rootBranchName) { - return ; - } + }, []); - if (complete) { + if (isCurrentBranchTracked) { return ( - {currentBranch.value} + {currentBranchName} {' '} - tracked with parent{' '} - - {complete} - - ! + is already a tracked branch ); } - if (isCurrentBranchTracked) { + if (state.isComplete) { return ( - {currentBranch.value} + {currentBranchName} {' '} - is already a tracked branch + tracked with parent{' '} + + {state.data.newParentBranchName} + + ! ); } @@ -76,7 +62,21 @@ function BranchTrack(_: CommandProps) { trackBranch({ parentBranch: item.value })} + onSelect={(item) => { + const _branch = loadBranch(currentBranchName); + const _newParentBranch = loadBranch(item.value); + assertBranchIsValidOrRoot(_newParentBranch); + + engine.trackBranch({ + branchName: _branch.name, + parentBranchName: _newParentBranch.name, + }); + + setState({ + isComplete: true, + data: { newParentBranchName: item.value }, + }); + }} /> ); } diff --git a/src/commands/changes/add.test.tsx b/src/commands/changes/add.test.tsx deleted file mode 100644 index 8a05507..0000000 --- a/src/commands/changes/add.test.tsx +++ /dev/null @@ -1,102 +0,0 @@ -import ChangesAdd from './add.js'; -import React from 'react'; -import { Loading } from '../../components/loading.js'; -import { Text } from 'ink'; -import { delay } from '../../utils/time.js'; -import { describe, expect, it, vi } from 'vitest'; -import { render } from 'ink-testing-library'; - -const ARBITRARY_DELAY = 120; // ms - -const mocks = vi.hoisted(() => { - return { - createGitService: vi.fn(({}) => { - return { - addAllFiles: async () => { - return new Promise((resolve) => - setTimeout(resolve, ARBITRARY_DELAY) - ); - }, - }; - }), - }; -}); - -vi.mock('../../services/git.js', () => { - return { - DEFAULT_OPTIONS: {}, - createGitService: mocks.createGitService, - }; -}); - -vi.mock('../../services/store.js', async () => { - const { mockStoreService } = await import('../../utils/test-helpers.js'); - return mockStoreService({ rootInitialized: false }); -}); - -const SUCCESS_MESSAGE = 'Staged all changes'; - -describe('correctly renders changes add UI', () => { - it('runs as intended', async () => { - const actual1 = render( - - ); - - const actual2 = render( - - ); - - const ExpectedComp = () => { - return ( - - {SUCCESS_MESSAGE} - - ); - }; - const expected = render(); - - await delay(ARBITRARY_DELAY + 250); - expect(actual1.lastFrame()).to.equal(expected.lastFrame()); - expect(actual2.lastFrame()).to.equal(expected.lastFrame()); - }); - - it('displays a loading state while processing', async () => { - const actual1 = render( - - ); - - const actual2 = render( - - ); - - const expected = render(); - - await delay(ARBITRARY_DELAY / 2); - expect(actual1.lastFrame()).to.equal(expected.lastFrame()); - expect(actual2.lastFrame()).to.equal(expected.lastFrame()); - }); -}); diff --git a/src/commands/changes/add.tsx b/src/commands/changes/add.tsx index cb6afee..3414f05 100644 --- a/src/commands/changes/add.tsx +++ b/src/commands/changes/add.tsx @@ -1,21 +1,18 @@ -import ErrorDisplay from '../../components/error-display.js'; -import React, { useCallback } from 'react'; -import { Action, useAction } from '../../hooks/use-action.js'; +import React from 'react'; import { CommandConfig, CommandProps } from '../../types.js'; -import { Loading } from '../../components/loading.js'; import { Text } from 'ink'; -import { useGit } from '../../hooks/use-git.js'; +import { git } from '../../modules/git.js'; +import { useAction } from '../../hooks/use-action.js'; const ChangedAdd = ({}: CommandProps) => { - const result = useChangesAdd(); - - if (result.isError) { - return ; - } + const result = useAction({ + func: () => { + git.assertInWorkTree(); + git.stageAllChanges(); + }, + }); - if (result.isLoading) { - return ; - } + if (!result.isComplete) return null; return ( @@ -24,18 +21,6 @@ const ChangedAdd = ({}: CommandProps) => { ); }; -const useChangesAdd = (): Action => { - const git = useGit(); - - const performAction = useCallback(async () => { - await git.addAllFiles(); - }, [git]); - - return useAction({ - asyncAction: performAction, - }); -}; - export const changesAddConfig: CommandConfig = { description: 'Stage all changes.', usage: 'changes add', diff --git a/src/commands/changes/commit.test.tsx b/src/commands/changes/commit.test.tsx deleted file mode 100644 index e54d13c..0000000 --- a/src/commands/changes/commit.test.tsx +++ /dev/null @@ -1,131 +0,0 @@ -import ChangesCommit from './commit.js'; -import React from 'react'; -import { Loading } from '../../components/loading.js'; -import { Text } from 'ink'; -import { delay } from '../../utils/time.js'; -import { describe, expect, it, vi } from 'vitest'; -import { render } from 'ink-testing-library'; - -const ARBITRARY_DELAY = 120; // ms - -const mocks = vi.hoisted(() => { - return { - createGitService: vi.fn(({}) => { - return { - checkout: async () => { - return new Promise((resolve) => - setTimeout(resolve, ARBITRARY_DELAY / 4) - ); - }, - currentBranch: async () => { - return new Promise((resolve) => - setTimeout(() => resolve('root'), ARBITRARY_DELAY / 4) - ); - }, - listBranches: async () => { - return new Promise((resolve) => - setTimeout( - () => resolve(['root', 'branch']), - ARBITRARY_DELAY / 4 - ) - ); - }, - createBranch: async () => { - return new Promise((resolve) => - setTimeout(resolve, ARBITRARY_DELAY / 4) - ); - }, - addAllFiles: async () => { - return new Promise((resolve) => - setTimeout(resolve, ARBITRARY_DELAY / 4) - ); - }, - commit: async ({ message }: { message: string }) => { - console.log(message); - return new Promise((resolve) => - setTimeout(resolve, ARBITRARY_DELAY / 4) - ); - }, - }; - }), - }; -}); - -vi.mock('../../services/git.js', () => { - return { - DEFAULT_OPTIONS: {}, - createGitService: mocks.createGitService, - }; -}); - -vi.mock('../../services/store.js', async () => { - const { mockStoreService } = await import('../../utils/test-helpers.js'); - return mockStoreService({ rootInitialized: true }); -}); - -const SUCCESS_MESSAGE = 'Committed all changes'; - -describe('correctly renders changes commit UI', () => { - // it('runs as intended', async () => { - // const actual1 = render( - // - // ); - // - // const actual2 = render( - // - // ); - // - // const ExpectedComp = () => { - // return ( - // - // {SUCCESS_MESSAGE} - // - // ); - // }; - // const expected = render(); - // - // await delay(ARBITRARY_DELAY + 250); - // expect(actual1.lastFrame()).to.equal(expected.lastFrame()); - // expect(actual2.lastFrame()).to.equal(expected.lastFrame()); - // }); - - it('displays a loading state while processing', async () => { - const actual1 = render( - - ); - - const actual2 = render( - - ); - - const expected = render(); - - await delay(ARBITRARY_DELAY / 4); - expect(actual1.lastFrame()).to.equal(expected.lastFrame()); - expect(actual2.lastFrame()).to.equal(expected.lastFrame()); - }); -}); diff --git a/src/commands/changes/commit.tsx b/src/commands/changes/commit.tsx index 4bd1860..b1feaf9 100644 --- a/src/commands/changes/commit.tsx +++ b/src/commands/changes/commit.tsx @@ -1,64 +1,37 @@ -import ErrorDisplay from '../../components/error-display.js'; -import React, { useCallback } from 'react'; -import { Action, useAction } from '../../hooks/use-action.js'; +import React from 'react'; import { CommandConfig, CommandProps, PropSanitationResult, Valid, } from '../../types.js'; -import { Loading } from '../../components/loading.js'; import { RecursiveRebaser } from '../../components/recursive-rebaser.js'; -import { SelectRootBranch } from '../../components/select-root-branch.js'; import { Text } from 'ink'; -import { UntrackedBranch } from '../../components/untracked-branch.js'; -import { useGit } from '../../hooks/use-git.js'; -import { useGitHelpers } from '../../hooks/use-git-helpers.js'; -import { useTree } from '../../hooks/use-tree.js'; +import { git } from '../../modules/git.js'; +import { useAction } from '../../hooks/use-action.js'; const ChangesCommit = (props: CommandProps) => { - const { currentBranch } = useGitHelpers(); - const { rootBranchName, isCurrentBranchTracked } = useTree(); - - if (!rootBranchName) { - return ; - } - - if (!isCurrentBranchTracked) { - return ; - } - - if (currentBranch.isLoading) { - return ; - } - - return ; -}; - -const DoChangesCommit = ({ - currentBranch, - ...props -}: CommandProps & { currentBranch: string }) => { const args = changesCommitConfig.getProps(props) as Valid< PropSanitationResult >; - const result = useChangesCommit({ - message: args.props.message, - }); + const currentBranchName = git.getCurrentBranchName(); - if (result.isError) { - return ; - } + const result = useAction({ + func: () => { + git.assertInWorkTree(); + git.assertHasDiff(); + git.stageAllChanges(); + git.commit({ message: args.props.message }); + }, + }); - if (result.isLoading) { - return ; - } + if (!result.isComplete) return null; return ( Committed changes successfully } @@ -66,19 +39,6 @@ const DoChangesCommit = ({ ); }; -const useChangesCommit = ({ message }: { message: string }): Action => { - const git = useGit(); - - const performAction = useCallback(async () => { - await git.addAllFiles(); - await git.commit({ message }); - }, []); - - return useAction({ - asyncAction: performAction, - }); -}; - interface CommandArgs { message: string; } diff --git a/src/commands/continue.tsx b/src/commands/continue.tsx index 0509e1a..ae5cba9 100644 --- a/src/commands/continue.tsx +++ b/src/commands/continue.tsx @@ -1,61 +1,100 @@ -import ErrorDisplay from '../components/error-display.js'; -import React, { useCallback, useState } from 'react'; -import { Action, useAction } from '../hooks/use-action.js'; +import React from 'react'; import { CommandConfig, CommandProps } from '../types.js'; -import { Loading } from '../components/loading.js'; +import { RebaseConflictError } from '../lib/errors.js'; +import { RecursiveRebaser } from '../components/recursive-rebaser.js'; import { Text } from 'ink'; -import { useGit } from '../hooks/use-git.js'; +import { git } from '../modules/git.js'; +import { isBranchWithParentBranchName } from '../modules/branch/assertions.js'; +import { loadBranch, updateMetadata } from '../modules/branch/wrapper.js'; +import { useAction } from '../hooks/use-action.js'; const Continue = (_: CommandProps) => { - const result = useRebaseContinue(); + const result = useAction<{ + noRebaseInProgress: boolean; + performRecursiveRebase?: boolean; + }>({ + func: () => { + if (!git.isRebasing()) { + return { noRebaseInProgress: true }; + } - if (result.isError) { - return ; - } - - if (result.isLoading) { - return ; - } + const _branch = loadBranch(git.getRebasingBranchName()); - if (!result.isRebaseInProgress) { - return No ongoing rebase found.; - } + if (!isBranchWithParentBranchName(_branch)) { + return { noRebaseInProgress: false }; + } - return null; -}; + const parentBranchName = _branch.parentBranchName; -type UseRebaseContinueResult = Action & { - isRebaseInProgress: boolean; -}; -const useRebaseContinue = (): UseRebaseContinueResult => { - const git = useGit(); - // assume a rebase is in action when this is called until proven otherwise - const [isRebaseInProgress, setIsRebaseInProgress] = useState(true); + const storedParentCommitHash = + 'parentCommitHash' in _branch ? _branch.parentCommitHash : null; + const correctParentCommitHash = git.getCurrentCommitHash({ + branchName: parentBranchName, + }); - const performAction = useCallback(async () => { - const isRebasing = await git.isRebasing(); - if (!isRebasing) { - setIsRebaseInProgress(false); - return; - } + /** + * If a rebase conflict has occurred, the user has resolved it, & we now need to + * continue, that means the branch metadata is in a broken state. + * + * The branch knows the correct new parent branch name, but not the new parent + * branch commit hash. This basically breaks tracking. + */ + updateMetadata({ + branchName: _branch.name, + metadata: { + parentCommitHash: correctParentCommitHash, + }, + }); - await git.rebaseContinue(); - }, [git]); + /** + * Do this last. If the metadata manipulation that comes before this fails, + * keep the index in a state where it still needs the user to call this command again. + */ + try { + git.rebaseContinue(); + } catch (e) { + if (git.isRebasing()) { + throw new RebaseConflictError(); + } else { + throw e; + } + } - const action = useAction({ - asyncAction: performAction, + return { + noRebaseInProgress: false, + performRecursiveRebase: + storedParentCommitHash !== correctParentCommitHash, + }; + }, }); - return { - ...action, - isRebaseInProgress, - } as UseRebaseContinueResult; + if (!result.isComplete) { + return null; + } + + if (result.data.noRebaseInProgress) { + return No ongoing rebase found.; + } + + if (result.data.performRecursiveRebase) { + const currentBranchName = git.getCurrentBranchName(); + return ( + + ); + } + + return null; }; export const continueConfig: CommandConfig = { description: 'Continues a rebase', usage: 'continue', key: 'continue', + aliases: ['cont'], getProps: () => { return { valid: true, diff --git a/src/commands/hop.test.tsx b/src/commands/hop.test.tsx deleted file mode 100644 index 12c2264..0000000 --- a/src/commands/hop.test.tsx +++ /dev/null @@ -1,141 +0,0 @@ -import GumptionItemComponent from '../components/gumption-item-component.js'; -import Hop from './hop.js'; -import React from 'react'; -import SelectInput from 'ink-select-input'; -import { KEYS, mockStoreService } from '../utils/test-helpers.js'; -import { delay } from '../utils/time.js'; -import { describe, expect, it, vi } from 'vitest'; -import { render } from 'ink-testing-library'; - -const mocks = vi.hoisted(() => { - return { - createGitService: vi.fn(({}) => { - return { - // eslint-disable-next-line @typescript-eslint/require-await - branchLocal: vi.fn(async () => ({ - all: ['branch1', 'branch2', 'branch3'], - current: 'branch1', - })), - checkout: async () => {}, - }; - }), - }; -}); - -vi.mock('../services/git.js', () => { - return { - DEFAULT_OPTIONS: {}, - createGitService: mocks.createGitService, - }; -}); - -vi.mock('../services/store.js', async () => { - const { mockStoreService } = await import('../utils/test-helpers.js'); - return mockStoreService({ rootInitialized: false }); -}); - -describe('correctly renders hop UI', () => { - // todo: lmao actually fix this - it('will stop annoying me now', () => { - expect(true).to.equal(true); - }); - // it('displays branch names in a list', async () => { - // const actual1 = render( - // - // ); - // - // const actual2 = render( - // - // ); - // - // const expected = render( - // - // ); - // - // await delay(100); - // expect(actual1.lastFrame()).to.equal(expected.lastFrame()); - // expect(actual2.lastFrame()).to.equal(expected.lastFrame()); - // }); - // - // it('explains when no branches match the searched pattern', async () => { - // const { lastFrame } = render( - // - // ); - // await delay(100); - // expect(lastFrame()).to.includes('No branches match the pattern'); - // expect(lastFrame()).to.includes('nonexistent-branch-name'); - // }); - // - // it('does not show all branches if too many', async () => { - // mocks.createGitService.mockImplementationOnce(({}) => { - // return { - // // eslint-disable-next-line @typescript-eslint/require-await - // branchLocal: vi.fn(async () => ({ - // all: Array.from({ length: 20 }, (_, i) => `branch${i + 1}`), - // current: 'not-included-in-list', - // })), - // checkout: async () => {}, - // }; - // }); - // const { lastFrame } = render( - // - // ); - // - // await delay(100); - // - // expect(lastFrame()).to.includes('branch10'); - // expect(lastFrame()).to.not.includes('branch11'); - // }); - // - // it('renders success message', async () => { - // const { lastFrame, stdin } = render( - // - // ); - // await delay(100); - // stdin.write(KEYS.down); - // await delay(100); - // stdin.write(KEYS.return); - // await delay(100); - // - // expect(lastFrame()).to.includes('branch1'); - // expect(lastFrame()).to.includes('โ†ด'); - // expect(lastFrame()).to.includes('Hopped to'); - // expect(lastFrame()).to.includes('branch3'); - // }); -}); diff --git a/src/commands/hop.tsx b/src/commands/hop.tsx index 7197929..fb0617b 100644 --- a/src/commands/hop.tsx +++ b/src/commands/hop.tsx @@ -1,72 +1,49 @@ -import ErrorDisplay from '../components/error-display.js'; -import React, { useCallback, useMemo, useState } from 'react'; +import React, { useMemo, useState } from 'react'; import { Box, Text } from 'ink'; import { CommandConfig, CommandProps } from '../types.js'; -import { Loading } from '../components/loading.js'; import { SearchSelectInput } from '../components/select-search-input.js'; -import { useGit } from '../hooks/use-git.js'; -import { useGitHelpers } from '../hooks/use-git-helpers.js'; +import { git } from '../modules/git.js'; const Hop = (_: CommandProps) => { - const git = useGit(); - - const { currentBranch, allBranches } = useGitHelpers(); - - const [newBranch, setNewBranch] = useState(undefined); - const [error, setError] = useState(undefined); - - const handleSelect = useCallback( - (item: { label: string; value: string }) => { - git.checkout(item.value) - .then(() => { - setNewBranch(item.label); - }) - .catch((error: Error) => { - setError(error); - }); - }, - [git, setNewBranch, setError] + const previousBranchName = git.getCurrentBranchName(); + const [newBranchName, setNewBranchName] = useState( + undefined ); const items = useMemo(() => { - if (currentBranch.isLoading || allBranches.isLoading) return []; - - if (allBranches.value.length === 1) { - return [{ label: currentBranch.value, value: currentBranch.value }]; - } - - return allBranches.value.map((branch) => ({ - label: branch, - value: branch, + return git.getLocalBranchNames().map((branchName) => ({ + label: branchName, + value: branchName, })); - }, [allBranches, currentBranch]); + }, []); - if (error) { - return ; - } - - if (currentBranch.isLoading || allBranches.isLoading) { - return ; - } - - if (newBranch) { + if (newBranchName) { return ( - {currentBranch.value} + {previousBranchName} โ†ด Hopped to{' '} - {newBranch} + {newBranchName} ); } - return ; + return ( + { + git.checkoutBranch(item.value); + setNewBranchName(item.label); + }} + /> + ); }; export const hopConfig: CommandConfig = { diff --git a/src/commands/list.tsx b/src/commands/list.tsx index 0d0f22d..2b69a44 100644 --- a/src/commands/list.tsx +++ b/src/commands/list.tsx @@ -1,37 +1,23 @@ import React from 'react'; import { Box } from 'ink'; import { CommandConfig } from '../types.js'; -import { Loading } from '../components/loading.js'; -import { SelectRootBranch } from '../components/select-root-branch.js'; import { TreeBranchDisplay } from '../utils/tree-display.js'; import { TreeDisplayProvider, useTreeDisplay, } from '../contexts/tree-display.context.js'; -import { useGitHelpers } from '../hooks/use-git-helpers.js'; -import { useTree } from '../hooks/use-tree.js'; +import { git } from '../modules/git.js'; export const List = () => { - const { currentBranch } = useGitHelpers(); - const { rootBranchName } = useTree(); - - if (!rootBranchName) { - return ; - } - - if (currentBranch.isLoading) { - return ; - } - return ( - - + + ); }; -const DoList = ({ currentBranch }: { currentBranch: string }) => { - const { nodes, maxWidth, branchNeedsRebaseRecord } = useTreeDisplay(); +const DoList = ({ currentBranchName }: { currentBranchName: string }) => { + const { nodes, maxWidth, branchNeedsRestackRecord } = useTreeDisplay(); return ( {nodes.map((node) => { @@ -39,10 +25,10 @@ const DoList = ({ currentBranch }: { currentBranch: string }) => { diff --git a/src/commands/move.tsx b/src/commands/move.tsx index 4b74bdf..ac5174b 100644 --- a/src/commands/move.tsx +++ b/src/commands/move.tsx @@ -1,31 +1,22 @@ -import React, { useCallback, useState } from 'react'; +import React, { useState } from 'react'; import SelectInput from 'ink-select-input'; import { Box, Text } from 'ink'; import { CommandConfig } from '../types.js'; -import { Loading } from '../components/loading.js'; import { RecursiveRebaser } from '../components/recursive-rebaser.js'; -import { SelectRootBranch } from '../components/select-root-branch.js'; import { TreeDisplayItemComponent } from '../components/tree-display-item-component.js'; import { TreeDisplayProvider, useTreeDisplay, } from '../contexts/tree-display.context.js'; -import { useGit } from '../hooks/use-git.js'; -import { useGitHelpers } from '../hooks/use-git-helpers.js'; -import { useTree } from '../hooks/use-tree.js'; +import { + assertBranchIsNotNone, + assertBranchIsValidAndNotRoot, +} from '../modules/branch/assertions.js'; +import { engine } from '../modules/engine.js'; +import { git } from '../modules/git.js'; +import { loadBranch } from '../modules/branch/wrapper.js'; export const Move = () => { - const { currentBranch } = useGitHelpers(); - const { rootBranchName } = useTree(); - - if (!rootBranchName) { - return ; - } - - if (currentBranch.isLoading) { - return ; - } - return ( @@ -34,56 +25,33 @@ export const Move = () => { }; const TreeBranchSelector = () => { - const git = useGit(); - const { moveOnto } = useTree(); - const { currentBranch } = useGitHelpers(); - const { nodes, isLoading: isLoadingTreeDisplay } = useTreeDisplay(); + const { nodes } = useTreeDisplay(); const [isFirstRebaseComplete, setIsFirstRebaseComplete] = useState(false); + const currentBranchName = git.getCurrentBranchName(); - const moveCurrentBranchToParent = useCallback( - async ({ - currentBranchName, - newParentBranchName, - }: { - currentBranchName: string; - newParentBranchName: string; - }) => { - // assign a new parent in the tree and rebase. Do the rebase first since it's more error prone. - await git.rebaseBranchOnto({ - branch: currentBranchName, - ontoBranch: newParentBranchName, - }); - moveOnto({ - branch: currentBranchName, - parent: newParentBranchName, - }); - }, - [git, moveOnto] - ); - - if (isLoadingTreeDisplay || currentBranch.isLoading) { - return ; - } - - if (!isFirstRebaseComplete && currentBranch.value) { + if (!isFirstRebaseComplete) { return ( Select the new parent for{' '} - {currentBranch.value} + {currentBranchName} ({ label: n.name, value: n.name }))} itemComponent={TreeDisplayItemComponent} onSelect={(item) => { - if (currentBranch.isLoading) return; + const currentBranch = loadBranch(currentBranchName); + assertBranchIsValidAndNotRoot(currentBranch); + + const newParentBranch = loadBranch(item.value); + assertBranchIsNotNone(newParentBranch); - void moveCurrentBranchToParent({ - currentBranchName: currentBranch.value, - newParentBranchName: item.value, - }).then(() => { - setIsFirstRebaseComplete(true); + engine.trackedRebase({ + branch: currentBranch, + ontoBranch: newParentBranch, }); + + setIsFirstRebaseComplete(true); }} limit={nodes.length} /> @@ -93,8 +61,8 @@ const TreeBranchSelector = () => { return ( Moved successfully} /> ); diff --git a/src/commands/switch.tsx b/src/commands/switch.tsx index acd00e4..098d89a 100644 --- a/src/commands/switch.tsx +++ b/src/commands/switch.tsx @@ -2,57 +2,42 @@ import React, { useState } from 'react'; import SelectInput from 'ink-select-input'; import { Box, Text } from 'ink'; import { CommandConfig } from '../types.js'; -import { Loading } from '../components/loading.js'; -import { SelectRootBranch } from '../components/select-root-branch.js'; import { TreeDisplayItemComponent } from '../components/tree-display-item-component.js'; import { TreeDisplayProvider, useTreeDisplay, } from '../contexts/tree-display.context.js'; -import { useGit } from '../hooks/use-git.js'; -import { useGitHelpers } from '../hooks/use-git-helpers.js'; -import { useTree } from '../hooks/use-tree.js'; +import { git } from '../modules/git.js'; export const Switch = () => { - const { currentBranch } = useGitHelpers(); - const { rootBranchName } = useTree(); - - if (!rootBranchName) { - return ; - } - - if (currentBranch.isLoading) { - return ; - } + git.assertInWorkTree(); return ( - + ); }; const TreeBranchSelector = () => { - const git = useGit(); - const { nodes, isLoading: isLoadingTreeDisplay } = useTreeDisplay(); - const [newBranch, setNewBranch] = useState(undefined); - const [isLoading, setIsLoading] = useState(false); + const { nodes } = useTreeDisplay(); + const [newBranchName, setNewBranchName] = useState( + undefined + ); - if (newBranch) { + if (newBranchName) { return ( - Hopped to{' '} + Switched to{' '} - {newBranch} + {newBranchName} ); } - if (isLoadingTreeDisplay || isLoading) { - return ; - } - return ( @@ -62,11 +47,9 @@ const TreeBranchSelector = () => { items={nodes.map((n) => ({ label: n.name, value: n.name }))} itemComponent={TreeDisplayItemComponent} onSelect={(item) => { - setIsLoading(true); - void git.checkout(item.value).then(() => { - setNewBranch(item.value); - setIsLoading(false); - }); + git.assertInWorkTree(); + git.checkoutBranch(item.value); + setNewBranchName(item.value); }} limit={nodes.length} /> diff --git a/src/commands/sync.tsx b/src/commands/sync.tsx index 275c8ff..1b469c4 100644 --- a/src/commands/sync.tsx +++ b/src/commands/sync.tsx @@ -1,72 +1,38 @@ -import ErrorDisplay from '../components/error-display.js'; -import React, { useCallback, useEffect, useMemo, useState } from 'react'; -import { Action, useAction } from '../hooks/use-action.js'; +import React, { useCallback, useEffect, useState } from 'react'; import { CommandConfig, CommandProps } from '../types.js'; import { ConfirmStatement } from '../components/confirm-statement.js'; -import { Loading } from '../components/loading.js'; import { RecursiveRebaser } from '../components/recursive-rebaser.js'; -import { SelectRootBranch } from '../components/select-root-branch.js'; import { Text } from 'ink'; -import { useGit } from '../hooks/use-git.js'; -import { useGitHelpers } from '../hooks/use-git-helpers.js'; -import { useTree } from '../hooks/use-tree.js'; +import { engine } from '../modules/engine.js'; +import { getRootBranch } from '../modules/branch/wrapper.js'; +import { git } from '../modules/git.js'; +import { tree } from '../modules/tree.js'; const Sync = (_: CommandProps) => { - const { currentBranch } = useGitHelpers(); - const { rootBranchName } = useTree(); + const rootBranch = getRootBranch(); + const originalBranchName = git.getCurrentBranchName(); + const { contestedBranchName, deleteBranch, skipContestedBranch } = + useSyncAction({ rootBranchName: rootBranch.name }); - if (!rootBranchName) { - return ; - } - - if (currentBranch.isLoading) { - return ; - } - - return ( - - ); -}; - -const DoSync = ({ - rootBranchName, - currentBranchName, -}: { - rootBranchName: string; - currentBranchName: string; -}) => { - const result = useSyncAction({ rootBranchName }); - - if (result.isError) { - return ; - } - - if (result.isLoading) { - return ; - } - - const { contestedBranch, deleteBranch, skipContestedBranch } = result; - - if (contestedBranch) { + if (contestedBranchName) { return ( It seems like{' '} - {contestedBranch} + {contestedBranchName} {' '} was deleted in the remote repository. Delete it locally? } onAccept={() => { - if (contestedBranch) void deleteBranch(contestedBranch); + if (contestedBranchName) + void deleteBranch(contestedBranchName); }} onDeny={() => { - if (contestedBranch) skipContestedBranch(contestedBranch); + if (contestedBranchName) + skipContestedBranch(contestedBranchName); }} /> ); @@ -74,17 +40,17 @@ const DoSync = ({ return ( Synced successfully} /> ); }; -type UseSyncActionResult = Action & { - deleteBranch: (branch: string) => Promise; - skipContestedBranch: (branch: string) => void; - contestedBranch: string | undefined; +type UseSyncActionResult = { + deleteBranch: (branchName: string) => Promise; + skipContestedBranch: (branchName: string) => void; + contestedBranchName: string | undefined; }; const useSyncAction = ({ @@ -92,64 +58,45 @@ const useSyncAction = ({ }: { rootBranchName: string; }): UseSyncActionResult => { - const git = useGit(); - const { removeBranch, get } = useTree(); - const [allContestedBranches, setAllContestedBranches] = useState( - [] - ); - - /** - * We need a snapshot of the tree instead of "currentTree", because deleting branches while doing cleanup will - * cause the "currentTree" to change, which problematically tries to re-trigger the whole sync process in the - * middle, which causes errors. - */ - const currentTreeSnapshot = useMemo(() => get(), []); - - const skipContestedBranch = useCallback((branch: string) => { - setAllContestedBranches((prev) => prev.filter((b) => b !== branch)); + const currentTree = tree.getTree(); + const [allContestedBranchNames, setAllContestedBranchNames] = useState< + string[] + >([]); + + const skipContestedBranch = useCallback((branchName: string) => { + setAllContestedBranchNames((prev) => + prev.filter((b) => b !== branchName) + ); }, []); const deleteBranch = useCallback( - async (branch: string) => { - // do the git branch delete first, since this is more error-prone - await git.branchDelete(branch); - removeBranch(branch); - skipContestedBranch(branch); + (branchName: string) => { + engine.deleteTrackedBranch({ branchName }); + skipContestedBranch(branchName); }, - [git, skipContestedBranch] + [skipContestedBranch] ); - const performAction = useCallback(async () => { - // todo: unsure if this is the correct condition - if (!currentTreeSnapshot.length) return; + useEffect(() => { + git.checkoutBranch(rootBranchName); + git.pull({ prune: true }); + const _contestedBranchNames = []; - await git.checkout(rootBranchName); - await git.pull(); - const contestedBranches = []; - - for (const node of currentTreeSnapshot) { - const closedOnRemote = await git.isClosedOnRemote(node.key); + for (const _branch of currentTree) { + const closedOnRemote = git.isClosedOnRemote({ + branchName: _branch.name, + }); if (closedOnRemote) { - contestedBranches.push(node.key); + _contestedBranchNames.push(_branch.name); } } - /** - * We need to update the state of contested branches all at once to prevent the user attempting to run the - * branch deletion function while a git.isClosedOnRemote() is running. Git does not allow multiple commands - * to be run in parallel and enforces this with an internal lockfile. - */ - setAllContestedBranches(contestedBranches); - }, [git, currentTreeSnapshot]); - - const action = useAction({ - asyncAction: performAction, - }); + setAllContestedBranchNames(_contestedBranchNames); + }, []); return { - ...action, // always get the first one, we're filtering the array until it is empty - contestedBranch: allContestedBranches[0], + contestedBranchName: allContestedBranchNames[0], deleteBranch, skipContestedBranch, } as UseSyncActionResult; diff --git a/src/components/gumption-error-boundary.tsx b/src/components/gumption-error-boundary.tsx new file mode 100644 index 0000000..cd87210 --- /dev/null +++ b/src/components/gumption-error-boundary.tsx @@ -0,0 +1,50 @@ +import ErrorDisplay from './error-display.js'; +import React, { ReactNode } from 'react'; +import { RebaseConflict } from './rebase-conflict.js'; +import { SelectRootBranch } from './select-root-branch.js'; +import { UntrackedBranch } from './untracked-branch.js'; + +interface GumptionErrorBoundaryProps { + children: ReactNode; +} + +interface GumptionErrorBoundaryState { + hasError: boolean; + error: Error | undefined; +} + +export class GumptionErrorBoundary extends React.Component< + GumptionErrorBoundaryProps, + GumptionErrorBoundaryState +> { + constructor(props: GumptionErrorBoundaryProps) { + super(props); + this.state = { hasError: false, error: undefined }; + } + + // @ts-expect-error - I'm tired + componentDidCatch(error: any) { + this.setState({ hasError: true, error: error as unknown as Error }); + } + + // @ts-expect-error - I'm tired + render() { + if (!this.state.hasError || !this.state.error) { + return this.props.children; + } + + if (this.state.error.name === 'NoRootBranchError') { + return ; + } + + if (this.state.error.name === 'UntrackedBranchError') { + return ; + } + + if (this.state.error.name === 'RebaseConflictError') { + return ; + } + + return ; + } +} diff --git a/src/components/loading.tsx b/src/components/loading.tsx index 7d2aec5..d6e807c 100644 --- a/src/components/loading.tsx +++ b/src/components/loading.tsx @@ -1,38 +1,38 @@ -import React, { useEffect, useMemo, useState } from 'react'; +import React, { useEffect, useState } from 'react'; import { Box, Text } from 'ink'; export const Loading = () => { return ; }; -const WIDTH = 3; +// const WIDTH = 3; const SYMBOLS = ['โ—”', 'โ—‘', 'โ—•', 'โ—']; export const InfiniteLoadingAnimation = () => { - const [barStartIndex, setBarStartIndex] = useState(0); + // const [barStartIndex, setBarStartIndex] = useState(0); const [symbolIndex, setSymbolIndex] = useState(0); useEffect(() => { - const loadingBarInterval = setInterval(() => { - setBarStartIndex((prev) => (prev + 1) % WIDTH); - }, 150); + // const loadingBarInterval = setInterval(() => { + // setBarStartIndex((prev) => (prev + 1) % WIDTH); + // }, 150); const symbolInterval = setInterval(() => { setSymbolIndex((prev) => (prev + 1) % SYMBOLS.length); }, 200); return () => { - clearInterval(loadingBarInterval); + // clearInterval(loadingBarInterval); clearInterval(symbolInterval); }; }, []); // alternative loading bar concept - const currentBar = useMemo(() => { - const blocks = Array(WIDTH).fill('โ–ˆ') as string[]; - blocks.splice(barStartIndex, 1, ' '); - return blocks; - }, [barStartIndex]); + // const currentBar = useMemo(() => { + // const blocks = Array(WIDTH).fill('โ–ˆ') as string[]; + // blocks.splice(barStartIndex, 1, ' '); + // return blocks; + // }, [barStartIndex]); return ( diff --git a/src/components/recursive-rebaser.tsx b/src/components/recursive-rebaser.tsx index 637b160..437f10e 100644 --- a/src/components/recursive-rebaser.tsx +++ b/src/components/recursive-rebaser.tsx @@ -1,33 +1,28 @@ -import ErrorDisplay from './error-display.js'; import React, { ReactNode } from 'react'; import { Box, Text } from 'ink'; import { Loading } from './loading.js'; import { RebaseActionLog, useRecursiveRebase, -} from '../hooks/use-recursive-rebase.js'; +} from '../modules/recursive-rebase/use-recursive-rebase.js'; import { RebaseConflict } from './rebase-conflict.js'; export const RecursiveRebaser = ({ - baseBranch, - endBranch, + baseBranchName, + endBranchName, successStateNode, }: { - baseBranch: string; - endBranch: string; + baseBranchName: string; + endBranchName?: string; successStateNode: ReactNode; }) => { - const result = useRecursiveRebase({ baseBranch, endBranch }); + const result = useRecursiveRebase({ baseBranchName, endBranchName }); - if (result.isError) { - return ; - } - - if (result.isLoading && !result.logs.length) { + if (result.status === 'NOT_STARTED') { return ; } - if (result.hasConflict) { + if (result.status === 'CONFLICT') { return ( @@ -36,7 +31,7 @@ export const RecursiveRebaser = ({ ); } - if (result.isComplete) { + if (result.status === 'COMPLETE') { return ( @@ -52,19 +47,57 @@ const Logs = ({ logs }: { logs: RebaseActionLog[] }) => { return ( {logs.map((action) => { - const isComplete = action.state === 'COMPLETED'; - return ( - ${action.ontoBranch}`}> - โ€บ {isComplete ? 'Rebased' : 'Rebasing'}{' '} - - {action.branch} - {' '} - onto{' '} - - {action.ontoBranch} + if (action.state === 'COMPLETED') { + return ( + ${action.ontoBranchName}`} + > + โ€บ Rebased{' '} + + {action.branchName} + {' '} + onto{' '} + + {action.ontoBranchName} + + + ); + } + + if (action.state === 'STARTED') { + return ( + ${action.ontoBranchName}`} + > + โ€บ Rebasing{' '} + + {action.branchName} + {' '} + onto{' '} + + {action.ontoBranchName} + - - ); + ); + } + + if (action.state === 'SKIPPED') { + return ( + ${action.ontoBranchName}`} + > + + {action.branchName} + {' '} + does not need to be rebased onto{' '} + + {action.ontoBranchName} + + + ); + } + + return null; })} ); diff --git a/src/components/select-root-branch.tsx b/src/components/select-root-branch.tsx index 0ea1d87..a74e8ed 100644 --- a/src/components/select-root-branch.tsx +++ b/src/components/select-root-branch.tsx @@ -1,53 +1,23 @@ -import React, { useCallback, useMemo, useState } from 'react'; +import React, { useMemo, useState } from 'react'; import { ConfirmStatement } from './confirm-statement.js'; -import { Loading } from './loading.js'; import { SearchSelectInput } from './select-search-input.js'; -import { Text, useInput } from 'ink'; -import { useAsyncValue } from '../hooks/use-async-value.js'; -import { useGit } from '../hooks/use-git.js'; -import { useTree } from '../hooks/use-tree.js'; +import { Text } from 'ink'; +import { git } from '../modules/git.js'; +import { setGumptionRootBranchName } from '../modules/repo-config.js'; export const SelectRootBranch = () => { - const git = useGit(); - const { registerRoot, rootBranchName } = useTree(); - const [search, setSearch] = useState(''); - const [unconfirmedRoot, setUnconfirmedRoot] = useState( + const [unconfirmedRootBranchName, setUnconfirmedRootBranchName] = useState< + string | undefined + >(undefined); + const [rootBranchName, setRootBranchName] = useState( undefined ); - const getAllBranches = useCallback(async (): Promise => { - const { all } = await git.branchLocal(); - return all; - }, [git]); - - const { value: allBranches, isLoading } = useAsyncValue({ - getValue: getAllBranches, - }); - - const handleSelect = (item: { label: string; value: string }) => { - setUnconfirmedRoot(item.value); - }; - - useInput( - (input, key) => { - if (key.backspace || key.delete) { - // remove final character - return setSearch((prev) => prev.slice(0, -1)); - } - - setSearch((prev) => `${prev}${input}`); - }, - { - isActive: !Boolean(unconfirmedRoot), - } - ); - const items = useMemo(() => { - if (!allBranches || isLoading) { - return []; - } - return allBranches.map((b) => ({ label: b, value: b })); - }, [search, allBranches]); + return git + .getLocalBranchNames() + .map((branchName) => ({ label: branchName, value: branchName })); + }, []); if (rootBranchName) { return ( @@ -57,25 +27,25 @@ export const SelectRootBranch = () => { ); } - if (!allBranches) { - return ; - } - - if (unconfirmedRoot) { + if (unconfirmedRootBranchName) { return ( - Set {unconfirmedRoot} as the - root branch? + Set{' '} + {unconfirmedRootBranchName}{' '} + as the root branch? } - onAccept={() => - unconfirmedRoot && registerRoot(unconfirmedRoot) - } + onAccept={() => { + unconfirmedRootBranchName && + setGumptionRootBranchName({ + rootBranchName: unconfirmedRootBranchName, + }); + setRootBranchName(unconfirmedRootBranchName); + }} onDeny={() => { - setUnconfirmedRoot(undefined); - setSearch(''); + setUnconfirmedRootBranchName(undefined); }} /> ); @@ -85,7 +55,9 @@ export const SelectRootBranch = () => { { + setUnconfirmedRootBranchName(item.value); + }} /> ); }; diff --git a/src/components/tree-display-item-component.tsx b/src/components/tree-display-item-component.tsx index 5336ef9..1473e9a 100644 --- a/src/components/tree-display-item-component.tsx +++ b/src/components/tree-display-item-component.tsx @@ -1,19 +1,19 @@ import React from 'react'; import { type ItemProps } from 'ink-select-input'; import { TreeBranchDisplay } from '../utils/tree-display.js'; -import { useGitHelpers } from '../hooks/use-git-helpers.js'; +import { git } from '../modules/git.js'; import { useTreeDisplay } from '../contexts/tree-display.context.js'; export const TreeDisplayItemComponent = ({ isSelected = false, label, }: ItemProps) => { - const { currentBranch } = useGitHelpers(); + const currentBranchName = git.getCurrentBranchName(); const { nodes, maxWidth } = useTreeDisplay(); const node = nodes.find((n) => n.name === label); - if (!node || currentBranch.isLoading) { + if (!node) { return null; } @@ -22,8 +22,8 @@ export const TreeDisplayItemComponent = ({ key={node.name} node={node} maxWidth={maxWidth} - isCurrent={node.name === currentBranch.value} - needsRebase={false} + isCurrent={node.name === currentBranchName} + needsRestack={false} underline={isSelected} /> ); diff --git a/src/components/untracked-branch.tsx b/src/components/untracked-branch.tsx index ddc6c43..f6e7b13 100644 --- a/src/components/untracked-branch.tsx +++ b/src/components/untracked-branch.tsx @@ -1,22 +1,17 @@ import React from 'react'; import { Box, Text } from 'ink'; -import { Loading } from './loading.js'; -import { useGitHelpers } from '../hooks/use-git-helpers.js'; +import { git } from '../modules/git.js'; const TRACK_BRANCH_COMMAND = 'gum branch track'; export const UntrackedBranch = () => { - const { currentBranch } = useGitHelpers(); - - if (currentBranch.isLoading) { - return ; - } + const currentBranchName = git.getCurrentBranchName(); return ( Cannot perform this operation on untracked branch{' '} - {currentBranch.value}. + {currentBranchName}. You can start tracking it with{' '} diff --git a/src/contexts/tree-display.context.tsx b/src/contexts/tree-display.context.tsx index 83d0ee9..bd68c90 100644 --- a/src/contexts/tree-display.context.tsx +++ b/src/contexts/tree-display.context.tsx @@ -1,53 +1,47 @@ -import React, { ReactNode, createContext, useContext, useMemo } from 'react'; +import React, { ReactNode, createContext, useContext } from 'react'; import { DisplayNode, getDisplayNodes, maxWidthFromDisplayNodes, } from '../utils/tree-display.js'; -import { treeToParentChildRecord } from '../utils/tree-helpers.js'; -import { useBranchNeedsRebaseRecord } from '../hooks/computed-values/use-branch-needs-rebase-record.js'; -import { useCleanCurrentTree } from '../hooks/computed-values/use-clean-current-tree.js'; -import { useTree } from '../hooks/use-tree.js'; +import { getRootBranch } from '../modules/branch/wrapper.js'; +import { tree, treeToParentChildRecord } from '../modules/tree.js'; +import { useBranchNeedsRestackRecord } from '../hooks/use-branch-needs-restack-record.js'; interface TreeDisplayContextType { maxWidth: number; nodes: DisplayNode[]; - branchNeedsRebaseRecord: Record; - isLoading: boolean; + branchNeedsRestackRecord: Record; } const TreeDisplayContext = createContext({ maxWidth: 0, nodes: [], - branchNeedsRebaseRecord: {}, - isLoading: true, + branchNeedsRestackRecord: {}, }); -export const TreeDisplayProvider = ({ children }: { children: ReactNode }) => { - const { rootBranchName } = useTree(); - - const { value: currentTree, isLoading: isLoadingCurrentTree } = - useCleanCurrentTree(); - - const treeParentChildRecord = useMemo( - () => treeToParentChildRecord(currentTree), - [currentTree] - ); - const { - value: branchNeedsRebaseRecord, - isLoading: isLoadingBranchNeedsRebaseRecord, - } = useBranchNeedsRebaseRecord({ currentTree }); - - const isLoading = useMemo(() => { - return isLoadingBranchNeedsRebaseRecord || isLoadingCurrentTree; - }, [isLoadingBranchNeedsRebaseRecord, isLoadingCurrentTree]); +export const TreeDisplayProvider = ({ + children, + options, +}: { + children: ReactNode; + options?: { includeBranchNeedsRebaseRecord?: boolean }; +}) => { + const rootBranchName = getRootBranch().name; + const _tree = tree.getTree(); + const treeParentChildRecord = treeToParentChildRecord(_tree); + const branchNeedsRestackRecord = useBranchNeedsRestackRecord({ + tree: _tree, + enabled: Boolean(options?.includeBranchNeedsRebaseRecord), + }); const nodes: DisplayNode[] = rootBranchName ? getDisplayNodes({ - record: treeParentChildRecord, + treeParentChildRecord, branchName: rootBranchName, }) : []; + const maxWidth = maxWidthFromDisplayNodes({ displayNodes: nodes }); return ( @@ -55,8 +49,7 @@ export const TreeDisplayProvider = ({ children }: { children: ReactNode }) => { value={{ maxWidth, nodes, - branchNeedsRebaseRecord, - isLoading, + branchNeedsRestackRecord, }} > {children} diff --git a/src/hooks/computed-values/use-branch-needs-rebase-record.ts b/src/hooks/computed-values/use-branch-needs-rebase-record.ts deleted file mode 100644 index 48b2d4e..0000000 --- a/src/hooks/computed-values/use-branch-needs-rebase-record.ts +++ /dev/null @@ -1,34 +0,0 @@ -import { Tree } from '../../services/tree.js'; -import { useAsyncValueWithDefault } from '../use-async-value.js'; -import { useCallback, useMemo } from 'react'; -import { useGit } from '../use-git.js'; - -export const useBranchNeedsRebaseRecord = ({ - currentTree, -}: { - currentTree: Tree; -}) => { - const git = useGit(); - - const getBranchNeedsRebaseRecord = useCallback(async () => { - const record: Record = {}; - - for (const _node of currentTree) { - if (!_node.parent) continue; - - record[_node.key] = await git.needsRebaseOnto({ - branch: _node.key, - ontoBranch: _node.parent, - }); - } - return record; - }, [currentTree, git.needsRebaseOnto]); - - // We need to memoize the default value to avoid infinite renders - const defaultValue = useMemo(() => ({}), []); - - return useAsyncValueWithDefault({ - getValue: getBranchNeedsRebaseRecord, - defaultValue, - }); -}; diff --git a/src/hooks/computed-values/use-clean-current-tree.ts b/src/hooks/computed-values/use-clean-current-tree.ts deleted file mode 100644 index d909bbc..0000000 --- a/src/hooks/computed-values/use-clean-current-tree.ts +++ /dev/null @@ -1,34 +0,0 @@ -import { Tree } from '../../services/tree.js'; -import { useAsyncValueWithDefault } from '../use-async-value.js'; -import { useCallback, useMemo } from 'react'; -import { useGit } from '../use-git.js'; -import { useTree } from '../use-tree.js'; - -/** - * The "cleaned" tree is the current tree without the branches that don't exist locally. - */ -export const useCleanCurrentTree = () => { - const git = useGit(); - const { currentTree: uncleanCurrentTree, removeBranch } = useTree(); - - const getCleanCurrentTree = useCallback(async () => { - const cleanTree: Tree = []; - for (const _node of uncleanCurrentTree) { - const branchExistsLocally = await git.branchExistsLocally( - _node.key - ); - - if (branchExistsLocally) cleanTree.push(_node); - else removeBranch(_node.key, { ignoreBranchDoesNotExist: true }); - } - return cleanTree; - }, [uncleanCurrentTree, git.branchExistsLocally, removeBranch]); - - // We need to memoize the default value to avoid infinite renders - const defaultValue = useMemo(() => [], []); - - return useAsyncValueWithDefault({ - getValue: getCleanCurrentTree, - defaultValue, - }); -}; diff --git a/src/hooks/use-action.ts b/src/hooks/use-action.ts index fb45466..41a532c 100644 --- a/src/hooks/use-action.ts +++ b/src/hooks/use-action.ts @@ -1,56 +1,28 @@ import { useEffect, useState } from 'react'; -export const useAction = ({ - asyncAction, +export type ActionState = + | { + isComplete: true; + data: TResult; + } + | { isComplete: false }; + +export const useAction = ({ + func, }: { - asyncAction: () => Promise; -}): Action => { - const [state, setState] = useState({ type: 'LOADING' }); + func: () => T; +}) => { + const [state, setState] = useState>({ + isComplete: false, + }); useEffect(() => { - setState({ type: 'LOADING' }); - asyncAction() - .then(() => setState({ type: 'COMPLETE' })) - .catch((e: Error) => { - setState({ type: 'ERROR', error: e }); - }); - }, [asyncAction]); - - if (state.type === 'ERROR') { - return { - isLoading: false, - isError: true, - error: state.error, - }; - } + const result = func(); + setState({ + isComplete: true, + data: result, + }); + }, []); - return { - isLoading: state.type === 'LOADING', - isError: false, - error: undefined, - }; + return state; }; - -export type Action = - | { - isLoading: boolean; - isError: false; - error: undefined; - } - | { - isLoading: boolean; - isError: true; - error: Error; - }; - -export type State = - | { - type: 'LOADING'; - } - | { - type: 'COMPLETE'; - } - | { - type: 'ERROR'; - error: Error; - }; diff --git a/src/hooks/use-async-value.ts b/src/hooks/use-async-value.ts deleted file mode 100644 index 5739ac7..0000000 --- a/src/hooks/use-async-value.ts +++ /dev/null @@ -1,46 +0,0 @@ -import { AsyncResult, AsyncResultWithDefault } from '../types.js'; -import { useEffect, useMemo, useState } from 'react'; - -type State = { type: 'LOADING' } | { type: 'COMPLETE'; value: T }; - -export const useAsyncValue = ({ - getValue, -}: { - getValue: () => Promise; -}): AsyncResult => { - const [state, setState] = useState>({ type: 'LOADING' }); - - useEffect(() => { - void (async () => { - const _value = await getValue(); - setState({ type: 'COMPLETE', value: _value }); - })(); - }, [getValue]); - - if (state.type !== 'COMPLETE') { - return { value: undefined, isLoading: true }; - } - - return { - value: state.value, - isLoading: false, - }; -}; - -export const useAsyncValueWithDefault = ({ - getValue, - defaultValue, -}: { - getValue: () => Promise; - defaultValue: T; -}): AsyncResultWithDefault => { - const result = useAsyncValue({ getValue }); - - return useMemo(() => { - if (result.isLoading) { - return { value: defaultValue, isLoading: true }; - } - - return { value: result.value, isLoading: false }; - }, [result.isLoading, result.value, defaultValue]); -}; diff --git a/src/hooks/use-branch-needs-restack-record.ts b/src/hooks/use-branch-needs-restack-record.ts new file mode 100644 index 0000000..4df457f --- /dev/null +++ b/src/hooks/use-branch-needs-restack-record.ts @@ -0,0 +1,38 @@ +import { GumptionTree } from '../modules/tree.js'; +import { git } from '../modules/git.js'; +import { + isBranchRoot, + isBranchWithParentBranchName, +} from '../modules/branch/assertions.js'; +import { useMemo } from 'react'; + +export const useBranchNeedsRestackRecord = ({ + tree, + enabled = true, +}: { + tree: GumptionTree; + enabled?: boolean; +}) => { + return useMemo(() => { + const record: Record = {}; + + if (!enabled) return record; + + for (const _branch of tree) { + if ( + isBranchRoot(_branch) || + !isBranchWithParentBranchName(_branch) + ) { + continue; + } + + const parentBranchName = _branch.parentBranchName; + record[_branch.name] = git.needsRebaseOnto({ + branchName: _branch.name, + ontoBranchName: parentBranchName, + }); + } + + return record; + }, [tree]); +}; diff --git a/src/hooks/use-git-helpers.ts b/src/hooks/use-git-helpers.ts deleted file mode 100644 index 5cbe4a4..0000000 --- a/src/hooks/use-git-helpers.ts +++ /dev/null @@ -1,34 +0,0 @@ -import { AsyncResult } from '../types.js'; -import { useAsyncValue } from './use-async-value.js'; -import { useCallback } from 'react'; -import { useGit } from './use-git.js'; - -interface UseGitHelpersResult { - currentBranch: AsyncResult; - allBranches: AsyncResult; -} - -export const useGitHelpers = (): UseGitHelpersResult => { - const git = useGit(); - - const getCurrentBranch = useCallback(async () => { - return await git.currentBranch(); - }, [git.currentBranch]); - - const currentBranchResult = useAsyncValue({ - getValue: getCurrentBranch, - }); - - const getBranches = useCallback(async () => { - return git.listBranches(); - }, [git.listBranches]); - - const allBranchesResult = useAsyncValue({ - getValue: getBranches, - }); - - return { - currentBranch: currentBranchResult, - allBranches: allBranchesResult, - }; -}; diff --git a/src/hooks/use-git.ts b/src/hooks/use-git.ts deleted file mode 100644 index 0618666..0000000 --- a/src/hooks/use-git.ts +++ /dev/null @@ -1,10 +0,0 @@ -import { - DEFAULT_OPTIONS, - GitService, - createGitService, -} from '../services/git.js'; -import { useMemo } from 'react'; - -export const useGit = (): GitService => { - return useMemo(() => createGitService({ options: DEFAULT_OPTIONS }), []); -}; diff --git a/src/hooks/use-recursive-rebase.tsx b/src/hooks/use-recursive-rebase.tsx deleted file mode 100644 index 02f155f..0000000 --- a/src/hooks/use-recursive-rebase.tsx +++ /dev/null @@ -1,92 +0,0 @@ -import React, { useCallback, useState } from 'react'; -import { Action, useAction } from './use-action.js'; -import { RebaseAction, recursiveRebase } from '../services/resolver.js'; -import { useGit } from './use-git.js'; -import { useTree } from './use-tree.js'; - -type UseRecursiveRebaseResult = Action & { - hasConflict: boolean; - isComplete: boolean; - logs: RebaseActionLog[]; -}; - -export type RebaseActionLog = RebaseAction & { - state: 'STARTED' | 'COMPLETED'; -}; - -export const useRecursiveRebase = ({ - baseBranch, - endBranch, -}: { - baseBranch: string; - endBranch: string; -}): UseRecursiveRebaseResult => { - const git = useGit(); - const { currentTree } = useTree(); - - const [hasConflict, setHasConflict] = useState(false); - const [isComplete, setIsComplete] = useState(false); - const [logs, setLogs] = useState([]); - - const performAction = useCallback(async () => { - try { - await recursiveRebase({ - tree: currentTree, - baseBranch: baseBranch, - endBranch: endBranch, - events: { - rebased: (action, state) => { - if (state === 'STARTED') { - setLogs((prev) => [ - ...prev, - { - ...action, - state: 'STARTED', - }, - ]); - return; - } - - setLogs((prev) => { - return prev.map((_action) => { - if (_action.state === 'COMPLETED') { - return _action; - } - - if ( - _action.branch !== action.branch || - _action.ontoBranch !== action.ontoBranch - ) { - return _action; - } - - return { - ..._action, - state: 'COMPLETED', - }; - }); - }); - }, - complete: () => setIsComplete(true), - }, - }); - } catch (e) { - const isRebasing = await git.isRebasing(); - if (!isRebasing) { - throw e; - } - setHasConflict(true); - } - }, [currentTree]); - - const action = useAction({ - asyncAction: performAction, - }); - - return { - ...action, - hasConflict, - isComplete, - logs, - } as UseRecursiveRebaseResult; -}; diff --git a/src/hooks/use-tree.ts b/src/hooks/use-tree.ts deleted file mode 100644 index b5d9d5f..0000000 --- a/src/hooks/use-tree.ts +++ /dev/null @@ -1,39 +0,0 @@ -import { Tree, TreeService, createTreeService } from '../services/tree.js'; -import { useGitHelpers } from './use-git-helpers.js'; -import { useMemo, useState } from 'react'; - -interface UseTreeResult extends TreeService { - currentTree: Tree; - rootBranchName: string | undefined; - isCurrentBranchTracked: boolean; - isLoading: boolean; -} - -export const useTree = (): UseTreeResult => { - const [currentTree, setCurrentTree] = useState([]); - const { currentBranch } = useGitHelpers(); - - const currentBranchResult = useMemo(() => { - if (currentBranch.isLoading) return { value: false, isLoading: true }; - const value = Boolean( - currentTree.find((b) => b.key === currentBranch.value) - ); - return { value, isLoading: false }; - }, [currentBranch]); - - const service = useMemo(() => createTreeService({ setCurrentTree }), []); - - const computed = useMemo(() => { - return { - rootBranchName: currentTree.find((b) => b.parent === null)?.key, - }; - }, [currentTree]); - - return { - currentTree, - ...computed, - ...service, - isCurrentBranchTracked: currentBranchResult.value, - isLoading: currentBranchResult.isLoading, - }; -}; diff --git a/src/lib/errors.ts b/src/lib/errors.ts new file mode 100644 index 0000000..bab497b --- /dev/null +++ b/src/lib/errors.ts @@ -0,0 +1,57 @@ +export class NoRootBranchError extends Error { + constructor() { + super(`Cannot perform this operation on the trunk branch.`); + this.name = 'NoRootBranchError'; + } +} + +export class NoBranchError extends Error { + constructor(branchName: string) { + super(`Could not find branch ${branchName}.`); + this.name = 'NoBranchError'; + } +} + +export class DetachedBranchError extends Error { + constructor() { + super(`Cannot perform this action without being on a branch`); + this.name = 'DetachedBranchError'; + } +} + +export class RebaseConflictError extends Error { + constructor() { + super(`Hit a conflict during rebase.`); + this.name = 'RebaseConflictError'; + } +} + +export class UntrackedBranchError extends Error { + constructor(branchName: string) { + super( + `Cannot perform this operation on untracked branch ${branchName}.` + ); + this.name = 'UntrackedBranchError'; + } +} + +export class NoDiffError extends Error { + constructor() { + super(`No changes have been made.`); + this.name = 'NoDiffError'; + } +} + +export class NoGitDirError extends Error { + constructor() { + super(`The current folder is not a git repository.`); + this.name = 'NoGitDirError'; + } +} + +export class NoWorkTreeError extends Error { + constructor() { + super(`This operation must be run in a work tree.`); + this.name = 'NoWorkTreeError'; + } +} diff --git a/src/modules/branch/assertions.ts b/src/modules/branch/assertions.ts new file mode 100644 index 0000000..8a25390 --- /dev/null +++ b/src/modules/branch/assertions.ts @@ -0,0 +1,102 @@ +import { + BranchState, + RootBranchState, + ValidOrRootBranchState, +} from './types.js'; +import { + NoBranchError, + NoDiffError, + NoRootBranchError, +} from '../../lib/errors.js'; +import { getGumptionRootBranchName } from '../repo-config.js'; +import { git } from '../git.js'; + +export const assertBranchNameExists = (branchName: string) => { + if (!git.checkBranchNameExists({ branchName })) { + throw new NoBranchError(branchName); + } +}; + +export const assertBranchNameIsNonRoot = (branchName: string) => { + if (branchName === getGumptionRootBranchName()) { + throw new Error('This action cannot be performed on the root branch.'); + } +}; + +export const assertCurrentHasDiff = () => { + if (!git.hasDiff()) { + throw new NoDiffError(); + } +}; + +export function assertBranchIsRoot( + branch: BranchState +): asserts branch is RootBranchState { + if (branch.condition !== 'IS_ROOT') throw new NoRootBranchError(); +} + +export function assertBranchIsValidOrRoot( + branch: BranchState +): asserts branch is ValidOrRootBranchState { + if (!['IS_ROOT', 'VALID'].includes(branch.condition)) { + throw new Error( + 'This action can only be performed on a valid Gumption branch.' + ); + } +} + +export function isBranchRoot(branch: BranchState): branch is RootBranchState { + return branch.condition === 'IS_ROOT'; +} + +export function isBranchNotNone( + branch: BranchState +): branch is Exclude { + return branch.condition !== 'NONE'; +} + +export function isBranchWithParentBranchName( + branch: BranchState +): branch is Extract< + BranchState, + { condition: 'VALID' | 'NO_PARENT_COMMIT_HASH' | 'PARENT_META_MISMATCH' } +> { + return ['VALID', 'NO_PARENT_COMMIT_HASH', 'PARENT_META_MISMATCH'].includes( + branch.condition + ); +} + +export function assertBranchIsNotRoot( + branch: BranchState +): asserts branch is Exclude { + if (branch.condition === 'IS_ROOT') { + throw new Error('This action cannot be performed on the root branch.'); + } +} + +export function assertBranchIsValidAndNotRoot( + branch: BranchState +): asserts branch is Exclude { + assertBranchIsValidOrRoot(branch); + assertBranchIsNotRoot(branch); +} + +export function assertBranchCanBeRebased( + branch: BranchState +): asserts branch is Exclude { + if (!['VALID', 'PARENT_META_MISMATCH'].includes(branch.condition)) { + throw new Error( + 'Only branches with sufficient stored metadata can be rebased.' + ); + } +} + +export function assertBranchIsNotNone( + branch: BranchState +): asserts branch is Exclude { + if (branch.condition === 'NONE') { + throw new Error( + 'This action can only be performed on a Gumption branch.' + ); + } +} diff --git a/src/modules/branch/cache.ts b/src/modules/branch/cache.ts new file mode 100644 index 0000000..bdc065f --- /dev/null +++ b/src/modules/branch/cache.ts @@ -0,0 +1,33 @@ +import { BranchState } from './types.js'; + +type BranchStateCache = Record; +type MaybeBranchStateCache = Record; +/** + * Store all local branches here and update accordingly. + * This saves many repetitive git calls. + * + * The implementation can be simple here since the "React app" + * only exists for the duration of the command. + */ +export const BRANCH_STATE_CACHE: BranchStateCache = {}; + +export function updateCache({ + branchName, + state, +}: { + branchName: string; + state: BranchState; +}) { + BRANCH_STATE_CACHE[branchName] = state; +} + +export function isCached(branchName: string): boolean { + return assertKeyInRecord(branchName, BRANCH_STATE_CACHE); +} + +export function assertKeyInRecord( + branchName: K, + record: MaybeBranchStateCache +): record is BranchStateCache { + return branchName in record; +} diff --git a/src/modules/branch/helper.ts b/src/modules/branch/helper.ts new file mode 100644 index 0000000..dfd524b --- /dev/null +++ b/src/modules/branch/helper.ts @@ -0,0 +1,23 @@ +import { branchMetadataRef } from './refs.js'; +import { execSync } from 'child_process'; +import { getGumptionRootBranchName } from '../repo-config.js'; + +export const isGumptionBranchOrRoot = ({ + branchName, +}: { + branchName: string; +}): boolean => { + const rootBranchName = getGumptionRootBranchName(); + if (branchName === rootBranchName) return true; + try { + const revision = execSync( + `git rev-parse --verify ${branchMetadataRef(branchName)} 2> /dev/null` + ) + .toString() + .trim(); + + return Boolean(revision); + } catch { + return false; + } +}; diff --git a/src/modules/branch/metadata.ts b/src/modules/branch/metadata.ts new file mode 100644 index 0000000..78716a4 --- /dev/null +++ b/src/modules/branch/metadata.ts @@ -0,0 +1,80 @@ +import { DeepNullable } from '../../types.js'; +import { JSONValue } from '../repo-config.js'; +import { branchMetadataRef } from './refs.js'; +import { execSync } from 'child_process'; + +export type BranchMetadata = { + parentBranchName: string; + parentCommitHash: string; +}; + +export const writeMetadataForBranch = ({ + branchName, + metadata, +}: { + branchName: string; + metadata: DeepNullable; +}) => { + const metadataBlobHash = execSync('git hash-object -w --stdin', { + input: JSON.stringify(metadata), + }).toString(); + + execSync( + `git update-ref ${branchMetadataRef(branchName)} ${metadataBlobHash}`, + { + stdio: 'ignore', + } + ); +}; + +export const updateMetadataForBranch = ({ + branchName, + metadata, +}: { + branchName: string; + metadata: Partial; +}) => { + const oldMetadata = getMetadataForBranch({ branchName }); + writeMetadataForBranch({ + branchName, + metadata: { + parentBranchName: null, + parentCommitHash: null, + ...oldMetadata, + ...metadata, + }, + }); +}; + +export const getMetadataForBranch = ({ + branchName, +}: { + branchName: string; +}): DeepNullable | null => { + try { + const ref = branchMetadataRef(branchName); + /** + * 2> /dev/null redirects the errors to a file. The file specified is /dev/null, a null device. + * So this functionally just says "disregard error outputs" + */ + const metadataStringified = execSync( + `git cat-file -p ${ref} 2> /dev/null` + ) + .toString() + .trim(); // ngl just trimming because I fear edge cases + + if (metadataStringified.length === 0) { + return null; + } + + const parsed = JSON.parse(metadataStringified) as JSONValue; + + if (typeof parsed !== 'object') { + return null; + } + + return parsed as DeepNullable; + } catch (e) { + return null; + } +}; diff --git a/src/modules/branch/refs.ts b/src/modules/branch/refs.ts new file mode 100644 index 0000000..1eed402 --- /dev/null +++ b/src/modules/branch/refs.ts @@ -0,0 +1,7 @@ +import { isDev } from '../../utils/dev.js'; + +const REF_METADATA_DIR = isDev ? 'gummy-test' : 'gumption-metadata'; + +export const branchMetadataRef = (branchName: string): string => { + return `refs/${REF_METADATA_DIR}/${branchName}`; +}; diff --git a/src/modules/branch/types.ts b/src/modules/branch/types.ts new file mode 100644 index 0000000..96b73b8 --- /dev/null +++ b/src/modules/branch/types.ts @@ -0,0 +1,54 @@ +export type BranchStateCondition = + // regular degular Gumption branches, we have the data we need + | 'VALID' + // we don't need to store any metadata for the root branch beyond knowing it's name + | 'IS_ROOT' + | 'NO_PARENT_BRANCH_NAME' + | 'NO_PARENT_COMMIT_HASH' + // denotes that we have a parent branch name and commit hash, but something is wrong (i.e. they don't match) + | 'PARENT_META_MISMATCH' + | 'NONE'; + +export type BranchState = { + name: string; + condition: BranchStateCondition; + currentCommitHash: string; +} & ( + | { + condition: 'VALID'; + parentBranchName: string; + parentCommitHash: string; + } + | { + condition: 'IS_ROOT'; + } + | { + condition: 'NO_PARENT_BRANCH_NAME'; + } + | { + condition: 'NO_PARENT_COMMIT_HASH'; + parentBranchName: string; + } + | { + condition: 'PARENT_META_MISMATCH'; + parentBranchName: string; + parentCommitHash: string; + } + | { + condition: 'NONE'; + } +); + +export type ValidOrRootBranchState = Extract< + BranchState, + { condition: 'VALID' | 'IS_ROOT' } +>; +export type ValidBranchState = Extract; +export type TrackedBranchState = Exclude; + +export type InvalidBranchState = Exclude< + BranchState, + { condition: 'VALID' | 'IS_ROOT' } +>; + +export type RootBranchState = Extract; diff --git a/src/modules/branch/wrapper.ts b/src/modules/branch/wrapper.ts new file mode 100644 index 0000000..6b17133 --- /dev/null +++ b/src/modules/branch/wrapper.ts @@ -0,0 +1,131 @@ +import { BRANCH_STATE_CACHE, isCached, updateCache } from './cache.js'; +import { + BranchMetadata, + getMetadataForBranch, + updateMetadataForBranch, +} from './metadata.js'; +import { BranchState, RootBranchState } from './types.js'; +import { NoRootBranchError } from '../../lib/errors.js'; +import { assertBranchIsRoot, assertBranchNameExists } from './assertions.js'; +import { getGumptionRootBranchName } from '../repo-config.js'; +import { git } from '../git.js'; + +export const loadBranch = (branchName: string): BranchState => { + const branch = _loadBranch(branchName); + + updateCache({ + branchName, + state: branch, + }); + + return branch; +}; + +export const _loadBranch = (branchName: string): BranchState => { + assertBranchNameExists(branchName); + + if (isCached(branchName)) { + /** + * @dark - use of "as" + */ + return BRANCH_STATE_CACHE[branchName] as BranchState; + } + + const coreBranch: Omit = getBranchCore({ + branchName, + }); + + const gumptionRootBranchName = getGumptionRootBranchName(); + if (branchName === gumptionRootBranchName) { + return { + condition: 'IS_ROOT', + ...coreBranch, + }; + } + + const metadata = getMetadataForBranch({ + branchName, + }); + + if (metadata === null) { + return { + condition: 'NONE', + ...coreBranch, + }; + } + + const { parentBranchName, parentCommitHash } = metadata; + + if (parentBranchName === null) { + return { + condition: 'NO_PARENT_BRANCH_NAME', + ...coreBranch, + }; + } + + if (parentCommitHash === null) { + return { + condition: 'NO_PARENT_COMMIT_HASH', + parentBranchName, + ...coreBranch, + }; + } + + const actualParentCommitHash = git.getCurrentCommitHash({ + branchName: parentBranchName, + }); + if (parentCommitHash !== actualParentCommitHash) { + return { + condition: 'PARENT_META_MISMATCH', + parentBranchName, + parentCommitHash, + ...coreBranch, + }; + } + + return { + condition: 'VALID', + parentBranchName, + parentCommitHash, + ...coreBranch, + }; +}; + +const getBranchCore = ({ + branchName, +}: { + branchName: string; +}): Omit => { + return { + name: branchName, + currentCommitHash: git.getCurrentCommitHash({ branchName }), + }; +}; + +export const getRootBranch = (): RootBranchState => { + const rootBranchName = getGumptionRootBranchName(); + if (!rootBranchName) throw new NoRootBranchError(); + + const rootBranch = loadBranch(rootBranchName); + assertBranchIsRoot(rootBranch); + return rootBranch; +}; + +/** + * All updates to metadata for a branch should be done with this function + */ +export const updateMetadata = ({ + branchName, + metadata, +}: { + branchName: string; + metadata: Partial; +}) => { + updateMetadataForBranch({ + branchName, + metadata, + }); + + // invalidate cache when metadata changes + delete BRANCH_STATE_CACHE[branchName]; +}; diff --git a/src/modules/engine.ts b/src/modules/engine.ts new file mode 100644 index 0000000..ad21586 --- /dev/null +++ b/src/modules/engine.ts @@ -0,0 +1,101 @@ +import { TrackedBranchState, ValidBranchState } from './branch/types.js'; +import { + assertBranchIsValidAndNotRoot, + assertBranchNameExists, + assertBranchNameIsNonRoot, +} from './branch/assertions.js'; +import { branchMetadataRef } from './branch/refs.js'; +import { git } from './git.js'; +import { loadBranch, updateMetadata } from './branch/wrapper.js'; +import { tree } from './tree.js'; + +interface EngineService { + trackedRebase: (args: { + branch: ValidBranchState; + ontoBranch: TrackedBranchState; + }) => void; + trackBranch: (args: { + branchName: string; + parentBranchName: string; + }) => void; + deleteTrackedBranch: (args: { branchName: string }) => void; + deleteBranchMetadata: (args: { branchName: string }) => void; +} + +export const createEngine = (): EngineService => { + return { + trackedRebase: ({ branch, ontoBranch }) => { + updateMetadata({ + branchName: branch.name, + metadata: { parentBranchName: ontoBranch.name }, + }); + + git.rebaseOnto({ + branchName: branch.name, + newParent: ontoBranch.name, + oldParent: branch.parentCommitHash, + }); + + updateMetadata({ + branchName: branch.name, + metadata: { parentCommitHash: ontoBranch.currentCommitHash }, + }); + }, + trackBranch: ({ branchName, parentBranchName }) => { + if (branchName === parentBranchName) { + throw new Error( + `Cannot track branch "${branchName}" with itself as a parent.` + ); + } + assertBranchNameIsNonRoot(branchName); + assertBranchNameExists(branchName); + assertBranchNameExists(parentBranchName); + + const mergeBase = git.mergeBase({ + a: branchName, + b: parentBranchName, + }); + + updateMetadata({ + branchName, + metadata: { + parentBranchName, + parentCommitHash: mergeBase, + }, + }); + }, + deleteTrackedBranch: ({ branchName }) => { + assertBranchNameExists(branchName); + + const currentBranchName = git.getCurrentBranchName(); + const branchToDelete = loadBranch(branchName); + + assertBranchIsValidAndNotRoot(branchToDelete); + + const parentBranchName = branchToDelete.parentBranchName; + + if (branchToDelete.name === currentBranchName) { + git.checkoutBranch(parentBranchName); + } + + tree.getChildren({ branchName }).forEach((branch) => { + updateMetadata({ + branchName: branch.name, + metadata: { + parentBranchName, + }, + }); + }); + + deleteBranchMetadata({ branchName }); + git.deleteBranch({ branchName, force: true }); + }, + deleteBranchMetadata, + }; +}; + +const deleteBranchMetadata = ({ branchName }: { branchName: string }) => { + git.deleteRef({ ref: branchMetadataRef(branchName) }); +}; + +export const engine = createEngine(); diff --git a/src/modules/git.ts b/src/modules/git.ts new file mode 100644 index 0000000..9127877 --- /dev/null +++ b/src/modules/git.ts @@ -0,0 +1,249 @@ +import { + NoDiffError, + NoWorkTreeError, + RebaseConflictError, +} from '../lib/errors.js'; +import { assertBranchNameExists } from './branch/assertions.js'; +import { execSync } from 'child_process'; + +const getLocalBranchNames = (): string[] => { + const branchOutput = execSync( + 'git branch --format="%(refname:short)"' + ).toString(); + + return branchOutput + .trim() + .split('\n') + .map((t) => t.trim()); +}; + +const getCurrentBranchName = (): string => { + const branchOutput = execSync('git branch --show-current') + .toString() + .trim(); + + // todo: if (!branchOutput) throw new DetachedBranchError(); + return branchOutput; +}; + +const checkBranchNameExists = ({ branchName }: { branchName: string }) => { + try { + execSync( + `git rev-parse --verify ${branchName} 2> /dev/null` + ).toString(); + return true; + } catch (e) { + return false; + } +}; + +const checkoutBranch = (branchName: string): void => { + assertBranchNameExists(branchName); + execSync(`git checkout ${branchName} 2> /dev/null`); +}; + +const stageAllChanges = () => { + execSync(`git add .`); +}; + +const hasDiff = (): boolean => { + // todo: I have NO idea why git diff does me dirty - case is add a new file with 0-1 letters in it, git diff says no changes + // const diff = execSync(`git diff`).toString().trim(); + const diff = execSync(`git add --dry-run --verbose .`).toString().trim(); + return Boolean(diff.length); +}; + +const commit = ({ message }: { message: string }) => { + const escapedMessage = message.replace(/"/g, '\\"'); + execSync(`git commit -m "${escapedMessage}"`); +}; + +const createBranch = ({ branchName }: { branchName: string }) => { + execSync(`git branch ${branchName}`); +}; + +const isClosedOnRemote = ({ branchName }: { branchName: string }): boolean => { + // this is only accurate if "git fetch -p" or some equivalent has been run + // recently enough to have up-to-date information in the refs + const output = execSync('git branch -a -vv').toString(); + + if (output.includes(`remotes/origin/${branchName}`)) { + // if remote ref is listed, then it's still a living remote branch in the upstream + return false; + } + + if (output.includes(`[origin/${branchName}: gone]`)) { + return true; + } + + return false; +}; + +const mergeBase = ({ a, b }: { a: string; b: string }): string => { + return execSync(`git merge-base ${a} ${b}`).toString().trim(); +}; + +const rebase = ({ + branchName, + newParent, +}: { + branchName: string; + newParent: string; +}): void => { + try { + execSync(`git rebase ${newParent} ${branchName} 2> /dev/null`); + } catch (e) { + if (git.isRebasing()) { + throw new RebaseConflictError(); + } else { + throw e; + } + } +}; + +const rebaseOnto = ({ + branchName, + newParent, + oldParent, +}: { + branchName: string; + newParent: string; + oldParent: string; +}): void => { + try { + execSync( + `git rebase --onto ${newParent} ${oldParent} ${branchName} 2> /dev/null` + ); + } catch (e) { + if (git.isRebasing()) { + throw new RebaseConflictError(); + } else { + throw e; + } + } +}; + +const needsRebaseOnto = ({ + branchName, + ontoBranchName, +}: { + branchName: string; + ontoBranchName: string; +}): boolean => { + assertBranchNameExists(branchName); + assertBranchNameExists(ontoBranchName); + + /* + * The result is the commit SHA of the most recent "ancestor" commit between both branches. + */ + const commonAncestorCommit = execSync( + `git merge-base ${branchName} ${ontoBranchName}` + ) + .toString() + .trim() + .replace('\n', ''); + + const ontoBranchLatestCommitHash = execSync( + `git rev-parse ${ontoBranchName}` + ) + .toString() + .trim(); + + return ontoBranchLatestCommitHash !== commonAncestorCommit; +}; + +const isRebasing = (): boolean => { + // see https://adamj.eu/tech/2023/05/29/git-detect-in-progress-operation/ + try { + const result = execSync( + 'git rev-parse --verify REBASE_HEAD 2> /dev/null' + ).toString(); + return Boolean(result); + } catch { + return false; + } +}; + +const rebaseContinue = (): void => { + execSync('git -c core.editor=true rebase --continue'); +}; + +const pull = ({ prune }: { prune: boolean }): void => { + execSync(`git pull --ff-only ${prune ? '--prune' : ''}`); +}; + +const deleteBranch = ({ + branchName, + force, +}: { + branchName: string; + force: boolean; +}): void => { + execSync(`git branch --delete ${force ? '--force' : ''} ${branchName}`); +}; + +const deleteRef = ({ ref }: { ref: string }): void => { + execSync(`git update-ref -d ${ref} 2> /dev/null`); +}; + +const getCurrentCommitHash = ({ branchName }: { branchName: string }) => { + return execSync(`git rev-parse ${branchName} 2> /dev/null`) + .toString() + .trim(); +}; + +/** + * fixme: highly suspect, but works + */ +const getRebasingBranchName = () => { + if (!isRebasing()) { + throw new Error('No rebase in progress.'); + } + + const gitDir = execSync('git rev-parse --absolute-git-dir') + .toString() + .trim(); + + const output = execSync(`cat ${gitDir}/rebase-merge/head-name`) + .toString() + .trim(); + + return output.replace('refs/heads/', ''); +}; + +const assertInWorkTree = () => { + const isInWorkTree = + execSync(`git rev-parse --is-inside-work-tree`).toString().trim() === + 'true'; + + if (!isInWorkTree) throw new NoWorkTreeError(); +}; + +const assertHasDiff = () => { + if (!hasDiff()) throw new NoDiffError(); +}; + +export const git = { + getLocalBranchNames, + getCurrentBranchName, + checkBranchNameExists, + checkoutBranch, + stageAllChanges, + hasDiff, + commit, + createBranch, + isClosedOnRemote, + mergeBase, + rebase, + rebaseOnto, + needsRebaseOnto, + isRebasing, + rebaseContinue, + pull, + deleteBranch, + deleteRef, + getCurrentCommitHash, + getRebasingBranchName, + assertInWorkTree, + assertHasDiff, +}; diff --git a/src/services/resolver.test.ts b/src/modules/recursive-rebase/recursive-rebase.test.ts similarity index 65% rename from src/services/resolver.test.ts rename to src/modules/recursive-rebase/recursive-rebase.test.ts index 13326a1..50c481c 100644 --- a/src/services/resolver.test.ts +++ b/src/modules/recursive-rebase/recursive-rebase.test.ts @@ -1,5 +1,5 @@ import { describe, expect, it } from 'vitest'; -import { getRebaseActions } from './resolver.js'; +import { getRebaseActions } from './recursive-rebase.js'; describe('rebase actions are planned correctly', () => { it('returns rebase actions at all', () => { @@ -9,7 +9,7 @@ describe('rebase actions are planned correctly', () => { expect( getRebaseActions({ parentChildRecord: record, - baseBranch: 'root', + baseBranchName: 'root', }).length ).to.be.greaterThan(0); }); @@ -18,7 +18,7 @@ describe('rebase actions are planned correctly', () => { expect( getRebaseActions({ parentChildRecord: {}, - baseBranch: 'branch_that_doesnt_exist', + baseBranchName: 'branch_that_doesnt_exist', }) ).to.deep.equal([]); }); @@ -33,39 +33,39 @@ describe('rebase actions are planned correctly', () => { const expected = [ { - branch: 'branch_a', - ontoBranch: 'root', + branchName: 'branch_a', + ontoBranchName: 'root', }, { - branch: 'branch_a_a', - ontoBranch: 'branch_a', + branchName: 'branch_a_a', + ontoBranchName: 'branch_a', }, { - branch: 'branch_a_a_a', - ontoBranch: 'branch_a_a', + branchName: 'branch_a_a_a', + ontoBranchName: 'branch_a_a', }, { - branch: 'branch_a_b', - ontoBranch: 'branch_a', + branchName: 'branch_a_b', + ontoBranchName: 'branch_a', }, { - branch: 'branch_b', - ontoBranch: 'root', + branchName: 'branch_b', + ontoBranchName: 'root', }, { - branch: 'branch_b_a', - ontoBranch: 'branch_b', + branchName: 'branch_b_a', + ontoBranchName: 'branch_b', }, { - branch: 'branch_b_b', - ontoBranch: 'branch_b', + branchName: 'branch_b_b', + ontoBranchName: 'branch_b', }, ]; expect( getRebaseActions({ parentChildRecord: record, - baseBranch: 'root', + baseBranchName: 'root', }) ).to.deep.equal(expected); }); @@ -74,7 +74,7 @@ describe('rebase actions are planned correctly', () => { expect( getRebaseActions({ parentChildRecord: { root: ['root'] }, - baseBranch: 'root', + baseBranchName: 'root', }) ).to.deep.equal( [], diff --git a/src/modules/recursive-rebase/recursive-rebase.ts b/src/modules/recursive-rebase/recursive-rebase.ts new file mode 100644 index 0000000..de7d089 --- /dev/null +++ b/src/modules/recursive-rebase/recursive-rebase.ts @@ -0,0 +1,135 @@ +import { GumptionTree, treeToParentChildRecord } from '../tree.js'; +import { + assertBranchCanBeRebased, + assertBranchIsValidOrRoot, +} from '../branch/assertions.js'; +import { engine } from '../engine.js'; +import { getGumptionRootBranchName } from '../repo-config.js'; +import { git } from '../git.js'; +import { loadBranch } from '../branch/wrapper.js'; + +export interface RebaseAction { + branchName: string; + ontoBranchName: string; +} + +export const getRebaseActions = ({ + parentChildRecord, + baseBranchName, +}: { + parentChildRecord: Record; + baseBranchName: string; +}) => { + if (!(baseBranchName in parentChildRecord)) { + return []; + } + + const rebaseActions: RebaseAction[] = []; + + const children = (parentChildRecord[baseBranchName] as string[]).filter( + (child) => { + /** + * Don't process children that are the same as their parent. + * This shouldn't ever happen, but causes an infinite loop if it does + */ + if (child === baseBranchName) return false; + return true; + } + ); + + // explore the tree with a DFS so an entire stack gets resolved before moving on + children.forEach((child) => { + rebaseActions.push({ + branchName: child, + ontoBranchName: baseBranchName, + }); + + rebaseActions.push( + ...getRebaseActions({ + parentChildRecord, + baseBranchName: child, + }) + ); + }); + + return rebaseActions.filter((action) => { + if (action.branchName === action.ontoBranchName) return false; + return true; + }); +}; + +export const recursiveRebase = ({ + tree, + baseBranchName, + endBranchName, + events, +}: { + tree: GumptionTree; + baseBranchName: string; + endBranchName?: string; + events?: { + rebased: ( + rebaseAction: RebaseAction, + state: 'STARTED' | 'COMPLETED' | 'SKIPPED' + ) => void; + complete: () => void; + }; +}) => { + const baseBranch_ = tree.find((branch) => branch.name === baseBranchName); + if (!baseBranch_) { + throw new Error(`${baseBranchName} is not in the tracked tree.`); + } + + const rebasedEventHandler = events?.rebased + ? events.rebased + : (_: RebaseAction, __: string) => {}; + + const completeEventHandler = events?.complete ? events.complete : () => {}; + + const record = treeToParentChildRecord(tree); + const rebaseActions = getRebaseActions({ + parentChildRecord: record, + baseBranchName: baseBranch_.name, + }); + + for (const rebaseAction of rebaseActions) { + if ( + !git.needsRebaseOnto({ + branchName: rebaseAction.branchName, + ontoBranchName: rebaseAction.ontoBranchName, + }) + ) { + rebasedEventHandler(rebaseAction, 'SKIPPED'); + continue; + } + + rebasedEventHandler(rebaseAction, 'STARTED'); + const _branch = loadBranch(rebaseAction.branchName); + assertBranchCanBeRebased(_branch); + + const _newParentBranch = loadBranch(rebaseAction.ontoBranchName); + assertBranchIsValidOrRoot(_newParentBranch); + + engine.trackedRebase({ + branch: _branch, + ontoBranch: _newParentBranch, + }); + rebasedEventHandler(rebaseAction, 'COMPLETED'); + } + + if (endBranchName) { + try { + git.checkoutBranch(endBranchName); + } catch (e) { + if ((e as Error).name !== 'NoBranchError') { + throw e; + } + const rootBranchName = getGumptionRootBranchName(); + if (rootBranchName) { + git.checkoutBranch(rootBranchName); + } + } + } + + completeEventHandler(); +}; diff --git a/src/modules/recursive-rebase/use-recursive-rebase.tsx b/src/modules/recursive-rebase/use-recursive-rebase.tsx new file mode 100644 index 0000000..afe5d24 --- /dev/null +++ b/src/modules/recursive-rebase/use-recursive-rebase.tsx @@ -0,0 +1,96 @@ +import { RebaseAction, recursiveRebase } from './recursive-rebase.js'; +import { git } from '../git.js'; +import { tree } from '../tree.js'; +import { useEffect, useState } from 'react'; + +type UseRecursiveRebaseResult = { + status: 'NOT_STARTED' | 'IN_PROGRESS' | 'CONFLICT' | 'COMPLETE'; + logs: RebaseActionLog[]; +}; + +export type RebaseActionLog = RebaseAction & { + state: 'STARTED' | 'COMPLETED' | 'SKIPPED'; +}; + +export const useRecursiveRebase = ({ + baseBranchName, + endBranchName, +}: { + baseBranchName: string; + endBranchName?: string; +}): UseRecursiveRebaseResult => { + const [state, setState] = useState<{ + status: UseRecursiveRebaseResult['status']; + }>({ status: 'NOT_STARTED' }); + const [logs, setLogs] = useState([]); + + useEffect(() => { + try { + recursiveRebase({ + tree: tree.getTree(), + baseBranchName, + endBranchName, + events: { + rebased: (action, state) => { + setState({ status: 'IN_PROGRESS' }); + + if (state === 'SKIPPED') { + setLogs((prev) => [ + ...prev, + { + ...action, + state: 'SKIPPED', + }, + ]); + return; + } + + if (state === 'STARTED') { + setLogs((prev) => [ + ...prev, + { + ...action, + state: 'STARTED', + }, + ]); + return; + } + + setLogs((prev) => { + return prev.map((_action) => { + if (_action.state === 'COMPLETED') { + return _action; + } + + if ( + _action.branchName !== action.branchName || + _action.ontoBranchName !== + action.ontoBranchName + ) { + return _action; + } + + return { + ..._action, + state: 'COMPLETED', + }; + }); + }); + }, + complete: () => setState({ status: 'COMPLETE' }), + }, + }); + } catch (e) { + if ((e as Error).name === 'RebaseConflictError') { + setState({ status: 'CONFLICT' }); + } else { + throw e; + } + } + }, []); + + return { + status: state.status, + logs, + } as UseRecursiveRebaseResult; +}; diff --git a/src/modules/repo-config.ts b/src/modules/repo-config.ts new file mode 100644 index 0000000..5fbbdb3 --- /dev/null +++ b/src/modules/repo-config.ts @@ -0,0 +1,74 @@ +import path from 'path'; +import { execSync } from 'child_process'; +import { existsSync, readFileSync, writeFileSync } from 'fs'; +import { isDev } from '../utils/dev.js'; + +const REPO_CONFIG_FILENAME = '.gumption_repo_config'; +const DEV_REPO_CONFIG_FILENAME = '.gumption_dev_repo_config'; + +type JSONPrimitive = string | number | boolean | null | undefined; + +export type JSONValue = + | JSONPrimitive + | JSONValue[] + | { + [key: string]: JSONValue; + }; + +interface PreflightResult { + filepath: string; +} + +const preflight = (): PreflightResult => { + const gitDir = execSync('git rev-parse --absolute-git-dir') + .toString() + .trim(); + const dataFilePath = path.join( + gitDir, + isDev ? DEV_REPO_CONFIG_FILENAME : REPO_CONFIG_FILENAME + ); + + return { + filepath: dataFilePath, + }; +}; + +const write = (data: JSONValue) => { + const { filepath } = preflight(); + writeFileSync(filepath, JSON.stringify(data, null, 2)); +}; + +const read = (): JSONValue => { + const { filepath } = preflight(); + if (!existsSync(filepath)) return null; + + const fileData = readFileSync(filepath, 'utf-8'); + if (fileData === '') return null; + return JSON.parse(fileData) as JSONValue; +}; + +interface GumptionConfig { + rootBranchName: string; +} + +const getConfig = (): Partial => { + return read() as Partial; +}; + +const setConfig = (config: Partial): void => { + return write(config as JSONValue); +}; + +export const getGumptionRootBranchName = (): string | null => { + return getConfig()?.rootBranchName ?? null; +}; + +export const setGumptionRootBranchName = ({ + rootBranchName, +}: { + rootBranchName: string; +}): void => { + setConfig({ + rootBranchName, + }); +}; diff --git a/src/modules/tree.ts b/src/modules/tree.ts new file mode 100644 index 0000000..446ee86 --- /dev/null +++ b/src/modules/tree.ts @@ -0,0 +1,76 @@ +import { BranchState } from './branch/types.js'; +import { NoRootBranchError } from '../lib/errors.js'; +import { + assertBranchIsValidOrRoot, + assertBranchNameExists, + isBranchNotNone, + isBranchWithParentBranchName, +} from './branch/assertions.js'; +import { getGumptionRootBranchName } from './repo-config.js'; +import { git } from './git.js'; +import { loadBranch } from './branch/wrapper.js'; + +export type GumptionTree = Exclude[]; +const getTree = (): GumptionTree => { + const rootBranchName = getGumptionRootBranchName(); + if (!rootBranchName) throw new NoRootBranchError(); + + const branchNames = git.getLocalBranchNames(); + + return branchNames + .map((branchName) => loadBranch(branchName)) + .filter((branch) => isBranchNotNone(branch)) as GumptionTree; +}; + +type TreeChild = Extract< + BranchState, + { condition: 'VALID' | 'NO_PARENT_COMMIT_HASH' | 'PARENT_META_MISMATCH' } +>; +const getChildren = ({ branchName }: { branchName: string }): TreeChild[] => { + assertBranchNameExists(branchName); + const branch = loadBranch(branchName); + assertBranchIsValidOrRoot(branch); + + /** + * @dark - do something smart to not need the as there + */ + return getTree().filter((_branch) => { + if (!isBranchWithParentBranchName(_branch)) return false; + return _branch.parentBranchName === branchName; + }) as TreeChild[]; +}; + +/** + * @returns a record mapping every branch name to the names of its child branches in the tree + */ +export const treeToParentChildRecord = ( + tree: GumptionTree +): Record => { + const record: Record = {}; + + tree.forEach((branch) => { + if (!isBranchWithParentBranchName(branch)) return; + + if (!(branch.name in record)) { + record[branch.name] = []; + } + + const parentBranchName = branch.parentBranchName; + + if (parentBranchName in record) { + const existingChildren = record[parentBranchName] as string[]; + record[parentBranchName] = [...existingChildren, branch.name]; + } + + if (!(parentBranchName in record)) { + record[parentBranchName] = [branch.name]; + } + }); + + return record; +}; + +export const tree = { + getTree, + getChildren, +}; diff --git a/src/services/git.ts b/src/services/git.ts deleted file mode 100644 index b577a75..0000000 --- a/src/services/git.ts +++ /dev/null @@ -1,209 +0,0 @@ -import { SimpleGit, SimpleGitOptions, simpleGit } from 'simple-git'; - -export const DEFAULT_OPTIONS: Partial = { - baseDir: process.cwd(), - binary: 'git', - maxConcurrentProcesses: 6, - trimmed: false, -}; - -export interface GitService { - _git: SimpleGit; - branchLocal: () => Promise>; - currentBranch: () => Promise; - listBranches: () => Promise; - checkout: ( - branch: string, - options?: { fallbackBranch?: string } - ) => Promise>; - addAllFiles: () => Promise; - commit: (args: { message: string }) => Promise; - createBranch: (args: { branchName: string }) => Promise; - rebaseBranchOnto: (args: { - branch: string; - ontoBranch: string; - }) => Promise; - isRebasing: () => Promise; - rebaseContinue: () => Promise; - mergeBaseBranch: (branchA: string, branchB: string) => Promise; - latestCommitFor: (branch: string) => Promise; - needsRebaseOnto: (args: { - branch: string; - ontoBranch: string; - }) => Promise; - isClosedOnRemote: (branch: string) => Promise; - fetchPrune: () => Promise; - pull: () => Promise; - branchDelete: (branch: string) => Promise; - branchExistsLocally: (branch: string) => Promise; -} - -export const createGitService = ({ - options, -}: { - options: Partial; -}): GitService => { - const gitEngine = simpleGit(options); - - async function branchExistsLocally(branch: string) { - // todo: we should probably be using this function a lot more lmao - const branchRef = await gitEngine.raw([ - 'show-ref', - `refs/heads/${branch}`, - ]); - return Boolean(branchRef); - } - - return { - _git: gitEngine, - // @ts-expect-error - being weird about the return type - branchLocal: async () => { - return gitEngine.branchLocal(); - }, - currentBranch: async () => { - const { current } = await gitEngine.branchLocal(); - return current; - }, - listBranches: async () => { - const { all } = await gitEngine.branchLocal(); - return all; - }, - // @ts-expect-error - being weird about the return type - checkout: async ( - branch: string, - options?: { - fallbackBranch?: string; - } - ) => { - const _branchExistsLocally = await branchExistsLocally(branch); - if (!_branchExistsLocally && options?.fallbackBranch) { - return gitEngine.checkout(options.fallbackBranch); - } - - return gitEngine.checkout(branch); - }, - addAllFiles: async () => { - await gitEngine.add('.'); - }, - commit: async ({ message }) => { - await gitEngine.commit(message); - }, - createBranch: async ({ branchName }: { branchName: string }) => { - await gitEngine.branch([branchName]); - }, - rebaseBranchOnto: async ({ - branch, - ontoBranch, - }: { - branch: string; - ontoBranch: string; - }) => { - await gitEngine.rebase([ontoBranch, branch]); - }, - isRebasing: async () => { - // see https://adamj.eu/tech/2023/05/29/git-detect-in-progress-operation/ - try { - const result = await gitEngine.revparse([ - '--verify', - 'REBASE_HEAD', - ]); - return Boolean(result); - } catch { - return false; - } - }, - rebaseContinue: async () => { - await gitEngine.raw([ - '-c', - 'core.editor=true', - 'rebase', - '--continue', - ]); - }, - mergeBaseBranch: async (branchA: string, branchB: string) => { - const result = await gitEngine.raw([ - 'merge-base', - branchA, - branchB, - ]); - /* - * The result is the commit SHA of the most recent "ancestor" commit between both branches. - * Because this is a raw() command, it also includes a "\n" at the end of the commit SHA that we remove - */ - const commonAncestorCommit = result.replace('\n', ''); - return commonAncestorCommit; - }, - latestCommitFor: async (branch: string) => { - const { latest } = await gitEngine.log([ - '-n', // specify a number of commits to return - '1', // only return 1 (the latest) - branch, - ]); - - return latest?.hash ?? null; - }, - needsRebaseOnto: async ({ - branch, - ontoBranch, - }: { - branch: string; - ontoBranch: string; - }) => { - if ( - !(await branchExistsLocally(branch)) || - !(await branchExistsLocally(branch)) - ) { - return false; - } - - const result = await gitEngine.raw([ - 'merge-base', - branch, - ontoBranch, - ]); - /* - * The result is the commit SHA of the most recent "ancestor" commit between both branches. - * Because this is a raw() command, it also includes a "\n" at the end of the commit SHA that we remove - */ - const commonAncestorCommit = result.replace('\n', ''); - - const { latest } = await gitEngine.log([ - '-n', // specify a number of commits to return - '1', // only return 1 (the latest) - ontoBranch, - ]); - - const ontoBranchLatestHash = latest?.hash ?? null; - - return ontoBranchLatestHash !== commonAncestorCommit; - }, - isClosedOnRemote: async (branch: string) => { - // this is only accurate is "git fetch -p" or some equivalent has been run - // recently enough to have up-to-date information in the refs - const { all, branches } = await gitEngine.branch(['-a', '-vv']); - const remoteBranchName = `remotes/origin/${branch}`; - - if (all.includes(remoteBranchName)) { - // if remote is still in the ref, then it's still a living remote branch in the upstream - return false; - } - - const label: string = branches?.[branch]?.label ?? ''; - if (label.includes(`[origin/${branch}: gone]`)) { - return true; - } - - return false; - }, - fetchPrune: async () => { - await gitEngine.fetch(['--prune', 'origin']); - }, - pull: async () => { - await gitEngine.pull(['--ff-only', '--prune']); - }, - branchDelete: async (branch: string) => { - await gitEngine.deleteLocalBranch(branch, true); - }, - branchExistsLocally, - }; -}; diff --git a/src/services/resolver.ts b/src/services/resolver.ts deleted file mode 100644 index dd285ce..0000000 --- a/src/services/resolver.ts +++ /dev/null @@ -1,102 +0,0 @@ -import { DEFAULT_OPTIONS, createGitService } from './git.js'; -import { Tree } from './tree.js'; -import { treeToParentChildRecord } from '../utils/tree-helpers.js'; - -export const recursiveRebase = async ({ - tree, - baseBranch, - endBranch, - events, -}: { - tree: Tree; - baseBranch: string; - endBranch: string; - events?: { - rebased: ( - rebaseAction: RebaseAction, - state: 'STARTED' | 'COMPLETED' - ) => void; - complete: () => void; - }; -}) => { - const baseBranchNode = tree.find((node) => node.key === baseBranch); - - if (!baseBranchNode) { - throw new Error(`${baseBranch} is not in the tracked tree.`); - } - - const rebasedEventHandler = events?.rebased - ? events.rebased - : (_: RebaseAction, __: string) => {}; - - const completeEventHandler = events?.complete ? events.complete : () => {}; - - const git = createGitService({ options: DEFAULT_OPTIONS }); - const record = treeToParentChildRecord(tree); - - const rebaseActions = getRebaseActions({ - parentChildRecord: record, - baseBranch: baseBranchNode.key, - }); - - for (const rebaseAction of rebaseActions) { - rebasedEventHandler(rebaseAction, 'STARTED'); - // todo: probably only do this if it's needed though, right? - await git.rebaseBranchOnto({ - branch: rebaseAction.branch, - ontoBranch: rebaseAction.ontoBranch, - }); - rebasedEventHandler(rebaseAction, 'COMPLETED'); - } - - const rootBranchName = tree.find((b) => b.parent === null)?.key; - await git.checkout(endBranch, { fallbackBranch: rootBranchName }); - completeEventHandler(); -}; - -export interface RebaseAction { - branch: string; - ontoBranch: string; -} - -export const getRebaseActions = ({ - parentChildRecord, - baseBranch, -}: { - parentChildRecord: Record; - baseBranch: string; -}) => { - if (!(baseBranch in parentChildRecord)) { - return []; - } - - const rebaseActions: RebaseAction[] = []; - - const children = (parentChildRecord[baseBranch] as string[]).filter( - (child) => { - // don't process children that are the same as their parent. This shouldn't ever happen, but causes an infinite loop if it does - if (child === baseBranch) return false; - return true; - } - ); - - // explore the "tree" with a DFS so an entire stack gets resolved before moving on - children.forEach((child) => { - rebaseActions.push({ - branch: child, - ontoBranch: baseBranch, - }); - - rebaseActions.push( - ...getRebaseActions({ - parentChildRecord, - baseBranch: child, - }) - ); - }); - - return rebaseActions.filter((action) => { - if (action.branch === action.ontoBranch) return false; - return true; - }); -}; diff --git a/src/services/store.ts b/src/services/store.ts deleted file mode 100644 index e6031e9..0000000 --- a/src/services/store.ts +++ /dev/null @@ -1,70 +0,0 @@ -import envPaths from 'env-paths'; -import path from 'path'; -import { existsSync, mkdirSync, readFileSync, writeFileSync } from 'fs'; -import { fileURLToPath } from 'url'; -import { isDev } from '../utils/dev.js'; - -// Recreate __dirname for ES Modules - from copilot -const __filename = fileURLToPath(import.meta.url).replace('/dist', ''); -const projectRootDir = path.resolve(path.dirname(__filename), '..'); - -type JSONPrimitive = string | number | boolean | null | undefined; - -export type JSONValue = - | JSONPrimitive - | JSONValue[] - | { - [key: string]: JSONValue; - }; - -interface PreflightResult { - filepath: string; -} - -const preflight = (config: ServiceConfig): PreflightResult => { - const paths = envPaths('gumption', { suffix: 'cli' }); - const dir = isDev ? path.join(projectRootDir, '.local-config') : paths.data; - const dataFilePath = path.join(dir, config.filename); - - if (!existsSync(dataFilePath)) { - mkdirSync(dir, { recursive: true }); - } - - return { - filepath: dataFilePath, - }; -}; - -const write = (data: JSONValue, config: ServiceConfig) => { - const { filepath } = preflight(config); - writeFileSync(filepath, JSON.stringify(data, null, 2)); -}; - -const read = (config: ServiceConfig): JSONValue => { - const { filepath } = preflight(config); - if (!existsSync(filepath)) return {}; - - const fileData = readFileSync(filepath, 'utf-8'); - if (fileData === '') return {}; - return JSON.parse(fileData) as JSONValue; -}; - -export interface StoreService { - write: (data: JSONValue) => void; - read: () => JSONValue; -} - -export interface ServiceConfig { - filename: string; -} - -export const createStoreService = (config: ServiceConfig): StoreService => { - return { - write: (data: JSONValue) => { - return write(data, config); - }, - read: () => { - return read(config); - }, - }; -}; diff --git a/src/services/tree.test.ts b/src/services/tree.test.ts deleted file mode 100644 index 6448ef8..0000000 --- a/src/services/tree.test.ts +++ /dev/null @@ -1,152 +0,0 @@ -import { createTreeService } from './tree.js'; -import { describe, expect, it, vi } from 'vitest'; - -vi.mock('./store.js', async () => { - const { mockStoreService } = await import('../utils/test-helpers.js'); - return mockStoreService({ rootInitialized: false }); -}); - -describe('tree service is working', () => { - it('registers root branch', () => { - const { registerRoot, get } = createTreeService(); - registerRoot('root'); - expect(get()).to.deep.equal([{ key: 'root', parent: null }]); - }); - - it('overwrites past roots with the new root branch', () => { - const { registerRoot, attachTo, get } = createTreeService(); - registerRoot('root'); - registerRoot('root_2'); - registerRoot('root_3'); - expect(get()).to.deep.equal([{ key: 'root_3', parent: null }]); - - registerRoot('root'); - attachTo({ newBranch: 'some-new-branch', parent: 'root' }); - attachTo({ newBranch: 'some-new-branch-2', parent: 'some-new-branch' }); - registerRoot('root_new'); - expect(get()).to.deep.equal([{ key: 'root_new', parent: null }]); - }); - - it('can attach a branch to a parent', () => { - const { registerRoot, attachTo, get } = createTreeService(); - registerRoot('root'); - attachTo({ newBranch: 'some-new-branch', parent: 'root' }); - expect(get()).to.deep.equal([ - { key: 'root', parent: null }, - { key: 'some-new-branch', parent: 'root' }, - ]); - }); - - it('can move a branch to a parent', () => { - const { registerRoot, attachTo, moveOnto, get } = createTreeService(); - registerRoot('root'); - attachTo({ newBranch: 'branch_a', parent: 'root' }); - attachTo({ newBranch: 'branch_b', parent: 'root' }); - attachTo({ newBranch: 'branch_a_a', parent: 'branch_a' }); - - expect(get()).to.deep.equal([ - { key: 'root', parent: null }, - { key: 'branch_a', parent: 'root' }, - { key: 'branch_b', parent: 'root' }, - { key: 'branch_a_a', parent: 'branch_a' }, - ]); - - moveOnto({ branch: 'branch_a_a', parent: 'root' }); - - expect(get()).to.deep.equal([ - { key: 'root', parent: null }, - { key: 'branch_a', parent: 'root' }, - { key: 'branch_b', parent: 'root' }, - { key: 'branch_a_a', parent: 'root' }, - ]); - - moveOnto({ branch: 'branch_b', parent: 'branch_a' }); - - expect(get()).to.deep.equal([ - { key: 'root', parent: null }, - { key: 'branch_a', parent: 'root' }, - { key: 'branch_b', parent: 'branch_a' }, - { key: 'branch_a_a', parent: 'root' }, - ]); - }); - - it('can remove branches', () => { - const { registerRoot, attachTo, removeBranch, get } = - createTreeService(); - - registerRoot('root'); - attachTo({ newBranch: 'branch_a', parent: 'root' }); - attachTo({ newBranch: 'branch_b', parent: 'root' }); - attachTo({ newBranch: 'branch_a_a', parent: 'branch_a' }); - attachTo({ newBranch: 'branch_b_a', parent: 'branch_b' }); - attachTo({ newBranch: 'branch_b_b', parent: 'branch_b' }); - - expect(get()).to.deep.equal([ - { key: 'root', parent: null }, - { key: 'branch_a', parent: 'root' }, - { key: 'branch_b', parent: 'root' }, - { key: 'branch_a_a', parent: 'branch_a' }, - { key: 'branch_b_a', parent: 'branch_b' }, - { key: 'branch_b_b', parent: 'branch_b' }, - ]); - - removeBranch('branch_a_a'); - - expect(get()).to.deep.equal([ - { key: 'root', parent: null }, - { key: 'branch_a', parent: 'root' }, - { key: 'branch_b', parent: 'root' }, - { key: 'branch_b_a', parent: 'branch_b' }, - { key: 'branch_b_b', parent: 'branch_b' }, - ]); - - removeBranch('branch_b'); - - expect(get()).to.deep.equal([ - { key: 'root', parent: null }, - { key: 'branch_a', parent: 'root' }, - { key: 'branch_b_a', parent: 'root' }, - { key: 'branch_b_b', parent: 'root' }, - ]); - }); - - it("removes parent branches without removing the child branches, but reattaches them to tree at the removed branch's parent", () => { - const { registerRoot, attachTo, removeBranch, get } = - createTreeService(); - - registerRoot('root'); - attachTo({ newBranch: 'branch_a', parent: 'root' }); - attachTo({ newBranch: 'branch_b', parent: 'root' }); - attachTo({ newBranch: 'branch_a_a', parent: 'branch_a' }); - attachTo({ newBranch: 'branch_b_a', parent: 'branch_b' }); - attachTo({ newBranch: 'branch_b_b', parent: 'branch_b' }); - - expect(get()).to.deep.equal([ - { key: 'root', parent: null }, - { key: 'branch_a', parent: 'root' }, - { key: 'branch_b', parent: 'root' }, - { key: 'branch_a_a', parent: 'branch_a' }, - { key: 'branch_b_a', parent: 'branch_b' }, - { key: 'branch_b_b', parent: 'branch_b' }, - ]); - - removeBranch('branch_a'); - - expect(get()).to.deep.equal([ - { key: 'root', parent: null }, - { key: 'branch_b', parent: 'root' }, - { key: 'branch_a_a', parent: 'root' }, - { key: 'branch_b_a', parent: 'branch_b' }, - { key: 'branch_b_b', parent: 'branch_b' }, - ]); - - removeBranch('branch_b'); - - expect(get()).to.deep.equal([ - { key: 'root', parent: null }, - { key: 'branch_a_a', parent: 'root' }, - { key: 'branch_b_a', parent: 'root' }, - { key: 'branch_b_b', parent: 'root' }, - ]); - }); -}); diff --git a/src/services/tree.ts b/src/services/tree.ts deleted file mode 100644 index 7008e0c..0000000 --- a/src/services/tree.ts +++ /dev/null @@ -1,269 +0,0 @@ -import { StoreService, createStoreService } from './store.js'; - -const FILENAME = 'branches.json'; - -export type BranchNode = { key: string; parent: string | null }; -export type Tree = BranchNode[]; - -type ParentBranch = string | symbol; -type SetTreeFunction = (tree: Tree) => void; - -const registerRoot = ( - branch: string, - deps: { storeService: StoreService; setCurrentTree: SetTreeFunction } -) => { - _saveTree([_createBranchNode({ tree: [], branch, parent: null })], deps); - deps.setCurrentTree(_readTree(deps)); -}; - -const attachTo = ( - { - newBranch, - parent, - }: { - newBranch: string; - parent: ParentBranch; - }, - deps: { storeService: StoreService; setCurrentTree: SetTreeFunction } -) => { - const tree = _readTree(deps); - const parentBranch = _findParent({ parent, tree }); - const newTree: Tree = [ - ...tree, - _createBranchNode({ - tree, - branch: newBranch, - parent: parentBranch.key, - }), - ]; - - _saveTree(newTree, deps); - deps.setCurrentTree(_readTree(deps)); -}; - -const moveOnto = ( - { - branch, - parent, - }: { - branch: string; - parent: ParentBranch; - }, - deps: { storeService: StoreService; setCurrentTree: SetTreeFunction } -) => { - const tree = _readTree(deps); - - if (branch === parent) { - throw Error('Cannot move a branch onto itself.'); - } - - const parentBranch = _findParent({ parent, tree }); - const targetBranch = _findBranch({ branch, tree }); - - targetBranch.parent = parentBranch.key; - _saveTree(tree, deps); - deps.setCurrentTree(_readTree(deps)); -}; - -const removeBranch = ( - branch: string, - deps: { storeService: StoreService; setCurrentTree: SetTreeFunction }, - options?: { ignoreBranchDoesNotExist?: boolean } -): BranchNode | undefined => { - const tree = _readTree(deps); - let branchToRemove: BranchNode; - - try { - branchToRemove = _findBranch({ branch, tree }); - } catch (e) { - if (options?.ignoreBranchDoesNotExist) return; - throw e; - } - - if (!branchToRemove) return; - - const root = _getRoot({ tree }); - if (root.key === branchToRemove.key) { - throw Error('Cannot remove root branch'); - } - - const removedBranchParent = branchToRemove.parent; - const treeWithBranchRemoved = tree - .filter((b) => b.key !== branchToRemove.key) - .map((b) => { - if (b.parent === branchToRemove.key) - return { ...b, parent: removedBranchParent }; - return b; - }); - - _saveTree(treeWithBranchRemoved, deps); - - deps.setCurrentTree(_readTree(deps)); - return branchToRemove; -}; - -const _createBranchNode = ({ - tree, - branch, - parent, -}: { - tree: Tree; - branch: string; - parent: string | null; -}): BranchNode => { - if (parent === null && tree.length) { - throw Error( - 'Tree already has a root branch. Only the root branch can have no parent.' - ); - } - - if (branch === parent) { - throw Error('Branch cannot be the same as parent.'); - } - - if (parent && !tree.find((n) => n.key === parent)) { - throw Error('Parent branch does not exist in tree.'); - } - - if (tree.find((n) => n.key === branch)) { - throw Error('Branch already exists in tree.'); - } - - return { - key: branch, - parent, - }; -}; - -const _getRoot = ({ tree }: { tree: Tree }) => { - const root = tree.find((b) => b.parent === null); - if (!root) { - throw Error('Root not found ๐Ÿคจ'); - } - - return root; -}; - -const _findBranch = ({ tree, branch }: { tree: Tree; branch: string }) => { - const branchNode = tree.find((b) => b.key === branch); - if (!branchNode) { - throw Error('Branch not found ๐Ÿ˜ฌ'); - } - - return branchNode; -}; - -const _saveTree = (tree: Tree, deps: { storeService: StoreService }) => { - const { storeService } = deps; - storeService.write(tree); -}; - -const _readTree = (deps: { - storeService: StoreService; - setCurrentTree: SetTreeFunction; -}) => { - const { storeService } = deps; - const data = storeService.read(); - - if (typeof data !== 'object') return []; - if (!Array.isArray(data)) return []; - - const isValidTree = data.every((el) => { - return el && el.hasOwnProperty('key') && el.hasOwnProperty('parent'); - }); - if (!isValidTree) { - return []; - } - - deps.setCurrentTree(data as Tree); - return data as Tree; -}; - -const _findParent = ({ - parent, - tree, -}: { - parent: ParentBranch; - tree: Tree; -}): BranchNode => { - let parentBranch: BranchNode; - switch (typeof parent) { - case 'string': - parentBranch = _findBranch({ branch: parent, tree }); - break; - case 'symbol': - if (parent !== ROOT) { - throw Error('Only the root branch can be accessed by symbol.'); - } - parentBranch = _getRoot({ tree }); - break; - default: - return parent; - } - - return parentBranch; -}; - -export interface TreeServiceConfig { - setCurrentTree?: SetTreeFunction; -} - -export interface TreeService { - registerRoot: (branch: string) => void; - attachTo: (args: { newBranch: string; parent: string }) => void; - moveOnto: (args: { branch: string; parent: string }) => void; - removeBranch: ( - branch: string, - options?: { ignoreBranchDoesNotExist?: boolean } - ) => void; - get: () => Tree; - getRoot: () => BranchNode | undefined; - ROOT: symbol; -} - -const ROOT = Symbol.for('ROOT'); - -export const createTreeService = (config?: TreeServiceConfig): TreeService => { - const storeService = createStoreService({ filename: FILENAME }); - - const setCurrentTree = config?.setCurrentTree - ? config.setCurrentTree - : (_: Tree) => {}; - - const service = { - registerRoot: (branch) => { - return registerRoot(branch, { storeService, setCurrentTree }); - }, - attachTo: (args) => { - return attachTo(args, { storeService, setCurrentTree }); - }, - moveOnto: (args) => { - return moveOnto(args, { storeService, setCurrentTree }); - }, - removeBranch: (branch, options) => { - return removeBranch( - branch, - { storeService, setCurrentTree }, - options - ); - }, - get: () => { - return _readTree({ storeService, setCurrentTree }); - }, - getRoot: () => { - const tree = _readTree({ storeService, setCurrentTree }); - try { - return _getRoot({ tree }); - } catch (e) { - return undefined; - } - }, - ROOT, - } as Omit; - - setCurrentTree(service.get()); - - return { - ...service, - }; -}; diff --git a/src/types.ts b/src/types.ts index b3af5cc..5be80ec 100644 --- a/src/types.ts +++ b/src/types.ts @@ -1,11 +1,9 @@ import { ComponentType } from 'react'; import { Result } from 'meow'; -export type AsyncResult = - | { value: T; isLoading: false } - | { value: undefined; isLoading: true }; - -export type AsyncResultWithDefault = { value: T; isLoading: boolean }; +export type DeepNullable = { + [P in keyof T]: T[P] | null; +}; export type SanitizeProps< T extends Record, diff --git a/src/utils/naming.ts b/src/utils/naming.ts index a618e46..20f6561 100644 --- a/src/utils/naming.ts +++ b/src/utils/naming.ts @@ -1,6 +1,14 @@ +import { git } from '../modules/git.js'; + export const safeBranchNameFromCommitMessage = (message: string): string => { - // todo: how to handle exact branch matches // Match every non-alphanumeric character that is not "-" or "_" const pattern = /[^a-zA-Z0-9\-_\/]+/gm; - return message.replace(pattern, '_').toLowerCase(); + let safeBranchName = message.replace(pattern, '_').toLowerCase(); + + // fixme: this is a lame way to do this, but ensures uniqueness + if (git.checkBranchNameExists({ branchName: safeBranchName })) { + safeBranchName = `${safeBranchName}_${Date.now()}`; + } + + return safeBranchName; }; diff --git a/src/utils/test-helpers.ts b/src/utils/test-helpers.ts index f0d8ede..d53d0e1 100644 --- a/src/utils/test-helpers.ts +++ b/src/utils/test-helpers.ts @@ -1,6 +1,3 @@ -import { JSONValue } from '../services/store.js'; -import { vi } from 'vitest'; - /** * Taken from https://github.com/milesj/boost/blob/master/packages/cli/tests/helpers.ts */ @@ -19,25 +16,3 @@ export const KEYS = { tab: '\t', up: '\u001B[A', }; - -export const mockStoreService = ({ - rootInitialized, -}: { - rootInitialized: boolean; -}) => { - let fileData = rootInitialized - ? '[{ "key": "root", "parent": null }]' - : '[]'; - return { - createStoreService: vi.fn(({}) => { - return { - read: (): JSONValue => { - return JSON.parse(fileData) as JSONValue; - }, - write: (data: JSONValue) => { - fileData = JSON.stringify(data); - }, - }; - }), - }; -}; diff --git a/src/utils/tree-display.tsx b/src/utils/tree-display.tsx index 104f188..8b2bed0 100644 --- a/src/utils/tree-display.tsx +++ b/src/utils/tree-display.tsx @@ -7,13 +7,13 @@ export const TreeBranchDisplay = ({ node, isCurrent, maxWidth, - needsRebase, + needsRestack, underline, }: { node: DisplayNode; isCurrent: boolean; maxWidth: number; - needsRebase: boolean; + needsRestack: boolean; underline: boolean; }) => { const style = styleMap[node.prefix.length % styleMap.length] as TextStyle; @@ -33,7 +33,7 @@ export const TreeBranchDisplay = ({ {isCurrent ? ' ๐Ÿ‘‰ ' : ''} {node.name}{' '} - {needsRebase && (Needs rebase)} + {needsRestack && (Needs restack)} ); @@ -94,14 +94,14 @@ const Spaces = ({ count }: { count: number }) => { }; export const getDisplayNodes = ({ - record, + treeParentChildRecord, branchName, childIndex = 0, parentPrefix = [], parentWidth = 0, depth = 0, }: { - record: Record; + treeParentChildRecord: Record; branchName: string; childIndex?: number; parentPrefix?: DisplayElement[]; @@ -109,7 +109,7 @@ export const getDisplayNodes = ({ depth?: number; }): DisplayNode[] => { const prefix = [...parentPrefix, ...prefixFromChildIndex({ childIndex })]; - const children = record[branchName] ?? []; + const children = treeParentChildRecord[branchName] ?? []; const suffix = suffixFromNumChildren({ numChildren: children.length, }); @@ -120,7 +120,7 @@ export const getDisplayNodes = ({ let nodes: DisplayNode[] = []; children.forEach((childBranch, index) => { const childNodes = getDisplayNodes({ - record, + treeParentChildRecord, branchName: childBranch, childIndex: index, parentPrefix: prefix, diff --git a/src/utils/tree-helpers.ts b/src/utils/tree-helpers.ts deleted file mode 100644 index 2c18ad8..0000000 --- a/src/utils/tree-helpers.ts +++ /dev/null @@ -1,28 +0,0 @@ -import { Tree } from '../services/tree.js'; - -/** - * @returns a record mapping every branch name to the names of its child branches in the tree - */ -export const treeToParentChildRecord = ( - tree: Tree -): Record => { - const record: Record = {}; - - tree.forEach((node) => { - if (!(node.key in record)) { - record[node.key] = []; - } - - if (node.parent === null) return; - - if (node.parent in record) { - const existingChildren = record[node.parent] as string[]; - record[node.parent] = [...existingChildren, node.key]; - } - if (!(node.parent in record)) { - record[node.parent] = []; - } - }); - - return record; -};