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;
-};