├── .github ├── FUNDING.yml └── workflows │ └── test.yml ├── .gitignore ├── .npmignore ├── .vscode └── launch.json ├── Documentation └── assets │ ├── git-rebase-todo │ └── git-rebase-todo.png ├── README.md ├── adapter-github.ts ├── apply.ts ├── argparse ├── argparse.spec.ts └── argparse.ts ├── autosquash.ts ├── branchSequencer.ts ├── config.ts ├── filenames.ts ├── forcePush.ts ├── git-reconcile-rewritten-list ├── README.md ├── combineRewrittenLists.ts ├── git-reconcile-rewritten-list.ts ├── package.json ├── postRewriteHook.ts ├── reducePath.spec.ts ├── tsconfig.json └── yarn.lock ├── git-stacked-rebase.ts ├── goodnight.sh ├── humanOp.ts ├── internal.ts ├── native-git ├── branch.ts ├── config.ts └── libgit-apis-in-use.js ├── nightly-setup-and-update.sh ├── nvim-git-rebase-todo ├── README ├── nvim-git-rebase-todo.ts ├── package.json ├── tsconfig.json └── yarn.lock ├── options.ts ├── package.json ├── parse-todo-of-stacked-rebase ├── parseNewGoodCommands.spec.ts ├── parseNewGoodCommands.ts ├── parseTodoOfStackedRebase.ts └── validator.ts ├── pullRequestStack.ts ├── ref-finder.ts ├── repair.ts ├── script ├── cloc.sh ├── cloc.ts.sh ├── postbuild.js └── prebuild.js ├── test ├── .gitignore ├── apply.spec.ts ├── auto-checkout-remote-partial-branches.spec.ts ├── experiment.spec.ts ├── non-first-rebase-has-initial-branch-cached.spec.ts ├── parse-argv-resolve-options.spec.ts ├── parseRangeDiff.spec.ts ├── run.ts └── util │ ├── setupRemoteRepo.ts │ ├── setupRepo.ts │ └── tmpdir.ts ├── tsconfig.json ├── util ├── Unpromise.ts ├── assertNever.ts ├── createQuestion.ts ├── delay.ts ├── error.ts ├── execSyncInRepo.ts ├── fs.ts ├── lockableArray.ts ├── log.ts ├── noop.ts ├── removeUndefinedProperties.ts ├── sequentialResolve.ts ├── stdout.ts ├── tmpdir.ts ├── tuple.ts └── uniq.ts └── yarn.lock /.github/FUNDING.yml: -------------------------------------------------------------------------------- 1 | github: [kiprasmel] 2 | 3 | -------------------------------------------------------------------------------- /.github/workflows/test.yml: -------------------------------------------------------------------------------- 1 | name: "tests" 2 | 3 | on: [push, workflow_dispatch] 4 | 5 | jobs: 6 | test: 7 | runs-on: ${{ matrix.os }} 8 | 9 | # https://docs.github.com/en/actions/using-workflows/workflow-syntax-for-github-actions#example-preventing-a-specific-failing-matrix-job-from-failing-a-workflow-run 10 | continue-on-error: ${{ matrix.experimental }} 11 | strategy: 12 | fail-fast: false 13 | matrix: 14 | os: [ubuntu-latest, macos-latest] 15 | node: [12, 14, 16, 18] 16 | experimental: [false] 17 | exclude: 18 | - os: macos-latest 19 | node: 12 20 | 21 | # nodegit binary missing... 22 | # 23 | # e.g. https://github.com/kiprasmel/git-stacked-rebase/actions/runs/4606760208/jobs/8140453365 24 | # 25 | # ``` 26 | # install response status 404 Not Found on 27 | # https://axonodegit.s3.amazonaws.com/nodegit/nodegit/nodegit-v0.28.0-alpha.18-node-v108-linux-x64.tar.gz 28 | # ``` 29 | # 30 | # see also: 31 | # - https://github.com/nodegit/nodegit/issues/1840#issuecomment-943083741 32 | # - https://github.com/nodegit/nodegit/issues/1840#issuecomment-1302139622 33 | - os: ubuntu-latest 34 | node: 18 35 | include: 36 | # test macos - for some reason, internet conn fails @ github actions 37 | - os: macos-latest 38 | node: 12 39 | experimental: true 40 | 41 | steps: 42 | - uses: actions/checkout@v2 43 | - uses: actions/setup-node@v3 44 | with: 45 | node-version: ${{ matrix.node }} 46 | cache: 'yarn' 47 | cache-dependency-path: '**/yarn.lock' 48 | - run: yarn --frozen-lockfile 49 | - run: yarn test 50 | # e2e 51 | - run: yarn install:all 52 | - run: yarn build:all 53 | - run: ./dist/git-stacked-rebase.js --debug 54 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | node_modules 2 | dist 3 | 4 | *.off 5 | *.log 6 | 7 | NEXT.md 8 | TODO.md 9 | 10 | refout* 11 | -------------------------------------------------------------------------------- /.npmignore: -------------------------------------------------------------------------------- 1 | !dist 2 | 3 | -------------------------------------------------------------------------------- /.vscode/launch.json: -------------------------------------------------------------------------------- 1 | { 2 | // Use IntelliSense to learn about possible attributes. 3 | // Hover to view descriptions of existing attributes. 4 | // For more information, visit: https://go.microsoft.com/fwlink/?linkid=830387 5 | "version": "0.2.0", 6 | "configurations": [ 7 | { 8 | "name": "node dist/git-stacked-rebase.js origin/master", 9 | "program": "${workspaceFolder}/dist/git-stacked-rebase.js", 10 | "request": "launch", 11 | "args": [ 12 | "origin/master" // 13 | ], 14 | "skipFiles": [ 15 | "/**" // 16 | ], 17 | "type": "node" 18 | }, 19 | { 20 | "name": "node dist/git-stacked-rebase.js origin/master --apply", 21 | "program": "${workspaceFolder}/dist/git-stacked-rebase.js", 22 | "request": "launch", 23 | "args": [ 24 | "origin/master", // 25 | "--apply" 26 | ], 27 | "skipFiles": [ 28 | "/**" // 29 | ], 30 | "type": "node" 31 | }, 32 | { 33 | "name": "ts-node tests", 34 | "type": "node", 35 | "request": "launch", 36 | "args": [ 37 | // "${relativeFile}" // 38 | "test/run.ts" 39 | ], 40 | "runtimeArgs": [ 41 | "-r", // 42 | "ts-node/register" 43 | ], 44 | "cwd": "${workspaceRoot}", 45 | "protocol": "inspector", 46 | "internalConsoleOptions": "openOnSessionStart", 47 | "env": { 48 | "DEBUG": "gsr:*" 49 | } 50 | }, 51 | { 52 | "name": "ts-node active file", 53 | "type": "node", 54 | "request": "launch", 55 | "args": ["${relativeFile}"], 56 | "runtimeArgs": ["-r", "ts-node/register"], 57 | "cwd": "${workspaceRoot}", 58 | "protocol": "inspector", 59 | "internalConsoleOptions": "openOnSessionStart" 60 | }, 61 | 62 | /** 63 | * seems broken 64 | * 65 | * instead, run: 66 | * ``` 67 | yarn build:core && ./script/postbuild.js 68 | GSR_DEBUG=1 node --inspect-brk ./dist/repair.js 69 | ``` 70 | * 71 | * & open debugger in chrome: 72 | * about://inspect 73 | * 74 | * works better than vscode. 75 | * 76 | * 77 | * can modify options & other stuff, e.g. how questions get answered, 78 | * directly in the file. 79 | * 80 | * 81 | * if don't need proper debugging experience, can just run 82 | * ``` 83 | GSR_DEBUG=1 ./repair.ts 84 | * ``` 85 | * 86 | */ 87 | { 88 | "name": "debug repair.ts (broken, see comment in launch.json how to debug)", 89 | "type": "node", 90 | "request": "launch", 91 | "args": ["repair.ts"], 92 | "runtimeArgs": ["--inspect-brk", "-r", "ts-node/register"], 93 | "cwd": "${workspaceRoot}", 94 | "env": { 95 | "GSR_DEBUG_REPAIR": "1", 96 | }, 97 | "internalConsoleOptions": "openOnSessionStart", 98 | } 99 | ] 100 | } 101 | -------------------------------------------------------------------------------- /Documentation/assets/git-rebase-todo: -------------------------------------------------------------------------------- 1 | branch-end-initial origin/master 2 | pick 4db230eac83756b1ebd813b999bb8ef3ce6e05ac feat: rebase with `--gpg-sign` if gpgSign enabled 3 | pick 6a9274c6bf2f92046a5bd2ef561ec97930d98d79 fix: disable gpgSign in tests 4 | branch-end feat/gpg-sign 5 | pick 39362110a80daefb82cc04f9df7647defe546911 print interactive rebase's hint that we're waiting the editor to close the file 6 | pick a11224c05b9f232692e978830b5d5193023cb315 exit with clear message if editor exits with error, instead of blowing up 7 | pick 72ce4dafb99af5fd3dcc58d20283e84374b16305 cleanup stacked-rebase dir if editor exits with error 8 | branch-end feat/editor-exit 9 | pick a690bdf21082cdec81dcb72fa86ce620f5d59263 refactor: rename `isFinalCheckout` to `isLatestBranch` (prereq) 10 | pick ee5691084155c3e658494625bd0997c97e45796e feat: implement `reverseCheckoutOrder` in `branchSequencer` 11 | pick 2000d00cafc70a4c40d852008f739f13c913c3d9 fix: use `reverseCheckoutOrder` in `forcePush` 12 | pick 6e294d0e24ca8f8aa8cece07e0f196761972db6d fix: remove initial branch; do use the latest branch 13 | pick 152b8875e5771af6bb125aa7f3309e0b1f30ba23 fix: checkout to the latest branch (since if reversed, would end up at initial) 14 | pick 64e8caa7e95886eb5ac8f57d5595341e3b4603c2 fix: move up the branch name fixing logic, use exec for final checkout too 15 | branch-end feat/allow-reverse-checkout-order 16 | pick 541ee5ed7986ddcba5a70dda7a60ed16b1ba748f use adaptive branch boundary finder behavior as the default in `branchSequencer` 17 | branch-end feat/pull 18 | pick d36df25ca4ae66877ccdee359b8abc95149c90e8 fix: expect only persistant commands to persist 19 | pick d86bcd98a38e92c949498106ad1c80e9ceca0d2c setup debug script to run --apply w/ built gsr for vscode 20 | pick ab049404d18a89ab980bebbbb263e56f9e7b9307 extract `setupRepo` and `humanOp` from experiment.spec.ts 21 | pick 2b48d674ea51631455076f675fc3666e7defb232 instead of Promise.all, resolve sequentially in test run because not concurrent-safe 22 | pick 586659263ca36465fa82c8d17a06fea5730f5f65 create test case for parseNewGoodCommands 23 | pick 9ba7444523cae9f247bb5608c836118b82b65de4 make tests cleanup deletable dirs in test/ 24 | branch-end-last fix/expect-only-persistant-commands-to-persist 25 | -------------------------------------------------------------------------------- /Documentation/assets/git-rebase-todo.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/kiprasmel/git-stacked-rebase/22a4ae05f3d6b2af713d5abea612dfc240039d14/Documentation/assets/git-rebase-todo.png -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # git-stacked-rebase 2 | 3 | stacked diffs in git, seamlessly. 4 | 5 |
6 | 7 |

8 | git-stacked-rebase 9 | is like git rebase -i, 10 | but it allows you to rebase stacked branches as well. 11 |

12 |
13 | 14 | previously, if you wanted to adopt a stacked branch workflow, you'd have to do a lot of manual work every time you'd update anything in your stack -- jump thru each branch, re-rebase it on top of the previous one, get rid of duplicate commits, resolve conflicts, try to remember what the next branch was, and repeat... 15 | 16 | there must be a better way. and that's exactly how git-stacked-rebase came to be. 17 | 18 | a branch is just a reference to some commit (literally, it's a single-line file that contains a 40-character commit SHA -- check your `.git/refs/` folder). why not just work on your latest feature branch, rebase comfortably, and then have your tool automatically update the partial branches to make them point to the correct new commits? 19 | 20 | from those partial branches, you can create pull requests. with this workflow, you get to comfortably iterate in a single branch; your teammates get the benefits of reviewing smaller PRs (when they're ready). win win. that's it. 21 | 22 | ## a note about git rebase --update-refs 23 | 24 | git-stacked-rebase got started 6 months before git rebase learned its own `--update-refs`. 25 | for the purposes of simply updating branches that are part of your stack, 26 | git's rebase with --update-refs works just fine. 27 | git-stacked-rebase still has unique features (and novel ideas yet to be implemented), 28 | but hasn't been maintained for a little while now. this may change when i get more capacity to work on it. 29 | 30 | --- 31 | 32 | `git-stacked-rebase` is not specific to any host like github or gitlab. it's "specific" to `git` itself. 33 | 34 | it's not only a CLI either - it's written as a javascript library, with the CLI directly on top. 35 | though, we're keeping our options open for a potential rewrite in C (read: we're designing it in a way 36 | to make a rewrite in C possible w/o breaking changes to end users, 37 | _if_ someone knowledgeable in git's core and C would want to take on this). 38 | 39 | in general, the design goal has always been for the experience to feel as similar to git as possible. 40 | and this is why i feel that it could eventually become part of core git. 41 | (it wasn't a "goal" per se, it just felt like the right approach.) 42 | 43 | nonetheless, there are other interesting things for us to explore, e.g.: 44 | - creating host-specific adapters - they could be used to automate some simple tasks, such as creating a pull request, or changing the base branch of a pull request, etc. 45 | - creating a browser extension to improve the experience of exploring stacked PRs. 46 | 47 | ## Progress 48 | 49 | follow [http://kiprasmel.github.io/notes/git-stacked-rebase.html](http://kiprasmel.github.io/notes/git-stacked-rebase.html) 50 | 51 | ## Setup 52 | 53 | dependencies: 54 | 55 | - git 56 | - a unix-like environment 57 | - [node.js](https://nodejs.org/en/) 58 | - tested versions: 12 thru 18, except v18 on linux. [see details](https://github.com/kiprasmel/git-stacked-rebase/blob/refactor1/.github/workflows/test.yml). 59 | - note that after installing node, you can install version managers, e.g. `npm i -g n`, to easily change node's version. 60 | - yarn (`npm i -g yarn`) 61 | 62 | 63 | 71 | 72 | once satisfied, run: 73 | 74 | ```sh 75 | git clone https://github.com/kiprasmel/git-stacked-rebase 76 | # or: git clone git@github.com:kiprasmel/git-stacked-rebase.git 77 | 78 | cd git-stacked-rebase 79 | 80 | ./nightly-setup-and-update.sh 81 | ``` 82 | 83 | [![nightly](https://img.shields.io/github/actions/workflow/status/kiprasmel/git-stacked-rebase/test.yml?label=nightly)](https://github.com/kiprasmel/git-stacked-rebase/actions/workflows/test.yml) 84 | 85 | ## Usage 86 | 87 | ```sh 88 | $ git-stacked-rebase --help 89 | 90 | git-stacked-rebase 91 | 92 | 0. usually should be a remote one, e.g. 'origin/master'. 93 | 1. will perform the interactive stacked rebase from HEAD to , 94 | 2. but will not apply the changes to partial branches until --apply is used. 95 | 96 | 97 | git-stacked-rebase [-a|--apply] 98 | 99 | 3. will apply the changes to partial branches, 100 | 4. but will not push any partial branches to a remote until --push is used. 101 | 102 | 103 | git-stacked-rebase [-p|--push -f|--force] 104 | 105 | 5. will push partial branches with --force (and extra safety), 106 | 6. but will not create any pull requests until --pull-request is used. 107 | 108 | 109 | git-stacked-rebase [--pr|--pull-request] 110 | 111 | 7. generates a list of URLs that can be used to create stacked PRs. 112 | (experimental, currently github-only.) 113 | 114 | 115 | git-stacked-rebase --repair 116 | 117 | (experimental) 118 | finds branches that have diverged, 119 | checks if they can be automatically re-integrated back into the stack, 120 | and performs the repair if user accepts. 121 | 122 | 123 | 124 | non-positional args: 125 | 126 | --autosquash, --no-autosquash 127 | 128 | handles "fixup!", "squash!" -prefixed commits 129 | just like --autosquash for a regular rebase does. 130 | 131 | can be enabled by default with the 'rebase.autosquash' option. 132 | 133 | 134 | --git-dir 135 | 136 | makes git-stacked-rebase begin operating inside the specified directory. 137 | 138 | 139 | --debug 140 | 141 | prints the debug directory where logs are stored. 142 | 143 | 144 | -V|--version 145 | -h|--help 146 | 147 | ``` 148 | -------------------------------------------------------------------------------- /adapter-github.ts: -------------------------------------------------------------------------------- 1 | import { Termination } from "./util/error"; 2 | 3 | export const createGithubURLForStackedPR = ({ 4 | repoOwner, // 5 | repo, 6 | baseBranch, 7 | newBranch, 8 | }: { 9 | repoOwner: string; 10 | repo: string; 11 | baseBranch: string; 12 | newBranch: string; 13 | }): string => `https://github.com/${repoOwner}/${repo}/compare/${baseBranch}...${newBranch}`; 14 | 15 | /** 16 | * TODO: support all formats properly, see: 17 | * - https://stackoverflow.com/a/31801532/9285308 18 | * - https://github.com/git/git/blob/master/urlmatch.c 19 | * - https://github.com/git/git/blob/master/urlmatch.h 20 | * - https://github.com/git/git/blob/master/t/t0110-urlmatch-normalization.sh 21 | */ 22 | export function parseGithubRemoteUrl(remoteUrl: string) { 23 | if (remoteUrl.startsWith("git@")) { 24 | // git@http://github.com:kiprasmel/git-stacked-rebase.git 25 | 26 | const hasHttp = remoteUrl.includes("http://") || remoteUrl.includes("https://"); 27 | if (hasHttp) { 28 | remoteUrl = remoteUrl.replace(/https?:\/\//, ""); 29 | } 30 | // git@github.com:kiprasmel/git-stacked-rebase.git 31 | 32 | // remove base url 33 | remoteUrl = remoteUrl.split(":").slice(1).join(":"); 34 | // kiprasmel/git-stacked-rebase.git 35 | 36 | if (remoteUrl.endsWith(".git")) { 37 | remoteUrl = remoteUrl.slice(0, -4); 38 | } 39 | // kiprasmel/git-stacked-rebase 40 | 41 | const [repoOwner, repo] = remoteUrl.split("/"); 42 | return { repoOwner, repo }; 43 | } else if (remoteUrl.startsWith("http")) { 44 | // https://github.com/kiprasmel/git-stacked-rebase.git 45 | 46 | const hasHttp = remoteUrl.includes("http://") || remoteUrl.includes("https://"); 47 | if (hasHttp) { 48 | remoteUrl = remoteUrl.replace(/https?:\/\//, ""); 49 | } 50 | // github.com/kiprasmel/git-stacked-rebase.git 51 | 52 | // remove base url 53 | remoteUrl = remoteUrl.split("/").slice(1).join("/"); 54 | // kiprasmel/git-stacked-rebase.git 55 | 56 | if (remoteUrl.endsWith(".git")) { 57 | remoteUrl = remoteUrl.slice(0, -4); 58 | } 59 | // kiprasmel/git-stacked-rebase 60 | 61 | const [repoOwner, repo] = remoteUrl.split("/"); 62 | return { repoOwner, repo }; 63 | } else { 64 | const msg = `\nUnrecognized URL format of remote: got "${remoteUrl}". Probably just un-implemented yet..\n\n`; 65 | throw new Termination(msg); 66 | } 67 | } 68 | -------------------------------------------------------------------------------- /apply.ts: -------------------------------------------------------------------------------- 1 | import fs from "fs"; 2 | import path from "path"; 3 | 4 | import Git from "nodegit"; 5 | import { combineRewrittenLists } from "./git-reconcile-rewritten-list/combineRewrittenLists"; 6 | 7 | import { AskQuestion, question, Questions } from "./util/createQuestion"; 8 | import { isDirEmptySync } from "./util/fs"; 9 | import { Termination } from "./util/error"; 10 | import { log } from "./util/log"; 11 | 12 | import { filenames } from "./filenames"; 13 | import { configKeys } from "./config"; 14 | // eslint-disable-next-line import/no-cycle 15 | import { 16 | BranchSequencerBase, // 17 | branchSequencer, 18 | ActionInsideEachCheckedOutBranch, 19 | BranchSequencerArgsBase, 20 | BehaviorOfGetBranchBoundaries, 21 | } from "./branchSequencer"; 22 | 23 | export const apply: BranchSequencerBase = (args) => 24 | branchSequencer({ 25 | ...args, 26 | actionInsideEachCheckedOutBranch: defaultApplyAction, 27 | // callbackAfterDone: defaultApplyCallback, 28 | delayMsBetweenCheckouts: 0, 29 | behaviorOfGetBranchBoundaries: BehaviorOfGetBranchBoundaries["parse-from-not-yet-applied-state"], 30 | reverseCheckoutOrder: false, 31 | }).then( 32 | (ret) => (markThatApplied(args.pathToStackedRebaseDirInsideDotGit), ret) // 33 | ); 34 | 35 | const defaultApplyAction: ActionInsideEachCheckedOutBranch = async ({ 36 | repo, // 37 | gitCmd, 38 | // targetBranch, 39 | targetCommitSHA, 40 | isLatestBranch, 41 | execSyncInRepo, 42 | }) => { 43 | const commit: Git.Commit = await Git.Commit.lookup(repo, targetCommitSHA); 44 | 45 | log("will reset to commit", commit.sha(), "(" + commit.summary() + ")"); 46 | 47 | log({ isLatestBranch }); 48 | 49 | if (!isLatestBranch) { 50 | /** 51 | * we're not using libgit's `Git.Reset.reset` here, because even after updating 52 | * to the latest version of nodegit (& they to libgit), 53 | * it still chokes when a user has specified an option `merge.conflictStyle` as `zdiff3` 54 | * (a newly added one in git, but it's been added like 4 months ago) 55 | */ 56 | // await Git.Reset.reset(repo, commit, Git.Reset.TYPE.HARD, {}); 57 | execSyncInRepo(`${gitCmd} reset --hard ${commit.sha()}`); 58 | 59 | // if (previousTargetBranchName) { 60 | // execSyncInRepo(`/usr/bin/env git rebase ${previousTargetBranchName}`); 61 | // } 62 | } 63 | }; 64 | 65 | export const getBackupPathOfPreviousStackedRebase = (pathToStackedRebaseDirInsideDotGit: string): string => 66 | pathToStackedRebaseDirInsideDotGit + ".previous"; 67 | 68 | export async function applyIfNeedsToApply({ 69 | repo, 70 | pathToStackedRebaseTodoFile, 71 | pathToStackedRebaseDirInsideDotGit, // 72 | autoApplyIfNeeded, 73 | isMandatoryIfMarkedAsNeeded, 74 | config, 75 | askQuestion = question, 76 | ...rest 77 | }: BranchSequencerArgsBase & { 78 | /** 79 | * i.e., sometimes a) we need the `--apply` to go thru, 80 | * and sometimes b) it's "resumable" on the next run, 81 | * i.e. we'd prefer to apply right now, 82 | * but it's fine if user does not apply now, 83 | * because `--apply` is resumable[1], 84 | * and on the next run of stacked rebase, user will be forced to apply anyway. 85 | * 86 | * [1] resumable, unless user runs into some edge case where it no longer is. 87 | * TODO: work out when this happens & handle better. 88 | */ 89 | isMandatoryIfMarkedAsNeeded: boolean; 90 | 91 | autoApplyIfNeeded: boolean; // 92 | config: Git.Config; 93 | askQuestion: AskQuestion; 94 | }): Promise { 95 | const needsToApply: boolean = doesNeedToApply(pathToStackedRebaseDirInsideDotGit); 96 | if (!needsToApply) { 97 | return; 98 | } 99 | 100 | if (isMandatoryIfMarkedAsNeeded) { 101 | /** 102 | * is marked as needed to apply, 103 | * and is mandatory -- try to get a confirmation that it is ok to apply. 104 | */ 105 | const userAllowedToApply = 106 | autoApplyIfNeeded || 107 | (await askYesNoAlways({ 108 | questionToAsk: Questions.need_to_apply_before_continuing, // 109 | askQuestion, 110 | onAllowAlways: async () => { 111 | await config.setBool(configKeys.autoApplyIfNeeded, 1); 112 | }, 113 | })); 114 | 115 | if (!userAllowedToApply) { 116 | const msg = "\ncannot continue without mandatory --apply. Exiting.\n"; 117 | throw new Termination(msg); 118 | } else { 119 | await apply({ 120 | repo, 121 | pathToStackedRebaseTodoFile, 122 | pathToStackedRebaseDirInsideDotGit, // 123 | ...rest, 124 | }); 125 | 126 | return; 127 | } 128 | } else { 129 | /** 130 | * is marked as needed to apply, 131 | * but performing the apply is NOT mandatory. 132 | * 133 | * thus, do NOT ask user if should apply -- only infer from config. 134 | */ 135 | 136 | if (autoApplyIfNeeded) { 137 | await apply({ 138 | repo, 139 | pathToStackedRebaseTodoFile, 140 | pathToStackedRebaseDirInsideDotGit, // 141 | ...rest, 142 | }); 143 | 144 | return; 145 | } else { 146 | /** 147 | * not mandatory, thus do nothing. 148 | */ 149 | } 150 | } 151 | } 152 | 153 | export type AskYesNoAlwaysCtx = { 154 | questionToAsk: Parameters[0]; 155 | askQuestion: AskQuestion; 156 | onAllowAlways?: () => void | Promise; 157 | }; 158 | 159 | export const askYesNoAlways = async ({ 160 | questionToAsk, // 161 | askQuestion = question, 162 | onAllowAlways = () => {}, 163 | }: AskYesNoAlwaysCtx): Promise => { 164 | const answer: string = await askQuestion(questionToAsk, { cb: (ans) => ans.trim().toLowerCase() }); 165 | 166 | const userAllowed: boolean = ["y", "yes", ""].includes(answer); 167 | const userAllowedAlways: boolean = ["a", "always"].includes(answer); 168 | 169 | if (userAllowedAlways) { 170 | await onAllowAlways(); 171 | } 172 | 173 | const allowed = userAllowed || userAllowedAlways; 174 | 175 | return allowed; 176 | }; 177 | 178 | const getPaths = ( 179 | pathToStackedRebaseDirInsideDotGit: string // 180 | ) => 181 | ({ 182 | rewrittenListPath: path.join(pathToStackedRebaseDirInsideDotGit, filenames.rewrittenList), 183 | needsToApplyPath: path.join(pathToStackedRebaseDirInsideDotGit, filenames.needsToApply), 184 | appliedPath: path.join(pathToStackedRebaseDirInsideDotGit, filenames.applied), 185 | gitRebaseTodoPath: path.join(pathToStackedRebaseDirInsideDotGit, filenames.gitRebaseTodo), 186 | } as const); 187 | 188 | export const markThatNeedsToApply = ( 189 | pathToStackedRebaseDirInsideDotGit: string // 190 | ): void => 191 | [getPaths(pathToStackedRebaseDirInsideDotGit)].map( 192 | ({ rewrittenListPath, needsToApplyPath, appliedPath }) => ( 193 | fs.existsSync(rewrittenListPath) 194 | ? fs.copyFileSync(rewrittenListPath, needsToApplyPath) 195 | : fs.writeFileSync(needsToApplyPath, ""), 196 | fs.existsSync(appliedPath) && fs.unlinkSync(appliedPath), 197 | void 0 198 | ) 199 | )[0]; 200 | 201 | export const isMarkedThatNeedsToApply = (pathToStackedRebaseDirInsideDotGit: string): boolean => { 202 | const pathToMark: string = getPaths(pathToStackedRebaseDirInsideDotGit).needsToApplyPath; 203 | return fs.existsSync(pathToMark); 204 | }; 205 | 206 | export const markThatApplied = (pathToStackedRebaseDirInsideDotGit: string): void => 207 | [getPaths(pathToStackedRebaseDirInsideDotGit)].map( 208 | ({ rewrittenListPath, needsToApplyPath, gitRebaseTodoPath }) => ( 209 | fs.existsSync(needsToApplyPath) && fs.unlinkSync(needsToApplyPath), // 210 | /** 211 | * need to check if the `rewrittenListPath` exists, 212 | * because even if it does not, then the "apply" can still go through 213 | * and "apply", by using the already .applied file, i.e. do nothing. 214 | * 215 | * TODO just do not run "apply" if the file doesn't exist? 216 | * or is there a case where it's useful still? 217 | * 218 | */ 219 | // fs.existsSync(rewrittenListPath) && fs.renameSync(rewrittenListPath, appliedPath), 220 | // // fs.existsSync(rewrittenListPath) 221 | // // ? fs.renameSync(rewrittenListPath, appliedPath) 222 | // // : !fs.existsSync(appliedPath) && 223 | // // (() => { 224 | // // throw new Error("applying uselessly"); 225 | // // })(), 226 | fs.existsSync(rewrittenListPath) && fs.unlinkSync(rewrittenListPath), 227 | fs.existsSync(gitRebaseTodoPath) && fs.unlinkSync(gitRebaseTodoPath), 228 | isDirEmptySync(pathToStackedRebaseDirInsideDotGit) && 229 | fs.rmdirSync(pathToStackedRebaseDirInsideDotGit, { recursive: true }), 230 | void 0 231 | ) 232 | )[0]; 233 | 234 | const doesNeedToApply = (pathToStackedRebaseDirInsideDotGit: string): boolean => { 235 | const { rewrittenListPath, needsToApplyPath, appliedPath } = getPaths(pathToStackedRebaseDirInsideDotGit); 236 | 237 | if (!fs.existsSync(rewrittenListPath)) { 238 | /** 239 | * nothing to apply 240 | */ 241 | return false; 242 | } 243 | 244 | const needsToApplyPart1: boolean = fs.existsSync(needsToApplyPath); 245 | if (needsToApplyPart1) { 246 | return true; 247 | } 248 | 249 | const needsToApplyPart2: boolean = fs.existsSync(appliedPath) 250 | ? /** 251 | * check if has been applied, but that apply is outdated 252 | */ 253 | !fs.readFileSync(appliedPath).equals(fs.readFileSync(rewrittenListPath)) 254 | : false; 255 | 256 | return needsToApplyPart2; 257 | }; 258 | 259 | export function readRewrittenListNotAppliedOrAppliedOrError(repoPath: string): { 260 | pathOfRewrittenList: string; 261 | pathOfRewrittenListApplied: string; 262 | rewrittenListRaw: string; 263 | /** 264 | * you probably want these: 265 | */ 266 | combinedRewrittenList: string; 267 | combinedRewrittenListLines: string[]; 268 | } { 269 | const pathOfRewrittenList: string = path.join(repoPath, "stacked-rebase", filenames.rewrittenList); 270 | const pathOfRewrittenListApplied: string = path.join(repoPath, "stacked-rebase", filenames.applied); 271 | 272 | /** 273 | * not combined yet 274 | */ 275 | let rewrittenListRaw: string; 276 | if (fs.existsSync(pathOfRewrittenList)) { 277 | rewrittenListRaw = fs.readFileSync(pathOfRewrittenList, { encoding: "utf-8" }); 278 | } else if (fs.existsSync(pathOfRewrittenListApplied)) { 279 | rewrittenListRaw = fs.readFileSync(pathOfRewrittenListApplied, { encoding: "utf-8" }); 280 | } else { 281 | throw new Error(`rewritten-list not found neither in ${pathOfRewrittenList}, nor in ${pathOfRewrittenListApplied}`); 282 | } 283 | 284 | const { combinedRewrittenList } = combineRewrittenLists(rewrittenListRaw); 285 | 286 | return { 287 | pathOfRewrittenList, 288 | pathOfRewrittenListApplied, 289 | rewrittenListRaw, 290 | combinedRewrittenList, 291 | combinedRewrittenListLines: combinedRewrittenList.split("\n").filter((line) => !!line), 292 | }; 293 | } 294 | -------------------------------------------------------------------------------- /argparse/argparse.spec.ts: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env ts-node-dev 2 | 3 | import assert from "assert"; 4 | 5 | import { NonPositional, NonPositionalWithValue, eatNonPositionals, eatNonPositionalsWithValues } from "./argparse"; 6 | 7 | export function argparse_TC() { 8 | eatNonPositionals_singleArg(); 9 | eatNonPositionals_multipleArgs(); 10 | 11 | eatNonPositionalsWithValues_singleArg(); 12 | eatNonPositionalsWithValues_multipleArgs(); 13 | } 14 | 15 | function eatNonPositionals_singleArg() { 16 | const argv = ["origin/master", "--autosquash", "foo", "bar"]; 17 | const targetArgs = ["--autosquash", "--no-autosquash"]; 18 | const expected: NonPositional[] = [{ argName: "--autosquash", origIdx: 1 }]; 19 | const expectedLeftoverArgv = ["origin/master", "foo", "bar"]; 20 | 21 | const parsed = eatNonPositionals(targetArgs, argv); 22 | 23 | assert.deepStrictEqual(parsed, expected); 24 | assert.deepStrictEqual(argv, expectedLeftoverArgv); 25 | } 26 | function eatNonPositionals_multipleArgs() { 27 | const argv = ["origin/master", "--autosquash", "foo", "bar", "--no-autosquash", "baz"]; 28 | const targetArgs = ["--autosquash", "--no-autosquash"]; 29 | const expected: NonPositional[] = [ 30 | { argName: "--autosquash", origIdx: 1 }, 31 | { argName: "--no-autosquash", origIdx: 4 }, 32 | ]; 33 | const expectedLeftoverArgv = ["origin/master", "foo", "bar", "baz"]; 34 | 35 | const parsed = eatNonPositionals(targetArgs, argv); 36 | 37 | assert.deepStrictEqual(parsed, expected); 38 | assert.deepStrictEqual(argv, expectedLeftoverArgv); 39 | } 40 | 41 | function eatNonPositionalsWithValues_singleArg() { 42 | const argv = ["origin/master", "--git-dir", "~/.dotfiles", "foo", "bar"]; 43 | const targetArgs = ["--git-dir", "--gd"]; 44 | const expected: NonPositionalWithValue[] = [{ argName: "--git-dir", origIdx: 1, argVal: "~/.dotfiles" }]; 45 | const expectedLeftoverArgv = ["origin/master", "foo", "bar"]; 46 | 47 | const parsed = eatNonPositionalsWithValues(targetArgs, argv); 48 | 49 | assert.deepStrictEqual(parsed, expected); 50 | assert.deepStrictEqual(argv, expectedLeftoverArgv); 51 | } 52 | function eatNonPositionalsWithValues_multipleArgs() { 53 | const argv = ["origin/master", "--git-dir", "~/.dotfiles", "foo", "bar", "--misc", "miscVal", "unrelatedVal"]; 54 | const targetArgs = ["--git-dir", "--gd", "--misc"]; 55 | const expected: NonPositionalWithValue[] = [ 56 | { argName: "--git-dir", origIdx: 1, argVal: "~/.dotfiles" }, 57 | { argName: "--misc", origIdx: 5, argVal: "miscVal" }, 58 | ]; 59 | const expectedLeftoverArgv = ["origin/master", "foo", "bar", "unrelatedVal"]; 60 | 61 | const parsed = eatNonPositionalsWithValues(targetArgs, argv); 62 | 63 | assert.deepStrictEqual(parsed, expected); 64 | assert.deepStrictEqual(argv, expectedLeftoverArgv); 65 | } 66 | 67 | if (!module.parent) { 68 | argparse_TC(); 69 | } 70 | -------------------------------------------------------------------------------- /argparse/argparse.ts: -------------------------------------------------------------------------------- 1 | import assert from "assert"; 2 | 3 | export type Maybe = T | undefined; 4 | 5 | export type Argv = string[]; 6 | export type MaybeArg = Maybe; 7 | 8 | /** 9 | * parses the argv. 10 | * mutates the `argv` array. 11 | */ 12 | export function createArgParse(argv: Argv) { 13 | const getArgv = (): Argv => argv; 14 | const peakNextArg = (): MaybeArg => argv[0]; 15 | const eatNextArg = (): MaybeArg => argv.shift(); 16 | const hasMoreArgs = (): boolean => argv.length > 0; 17 | 18 | return { 19 | getArgv, 20 | peakNextArg, 21 | eatNextArg, 22 | hasMoreArgs, 23 | eatNonPositionals: (argNames: string[]) => eatNonPositionals(argNames, argv), 24 | eatNonPositionalsWithValues: (argNames: string[]) => eatNonPositionalsWithValues(argNames, argv), 25 | }; 26 | } 27 | 28 | export type NonPositional = { 29 | origIdx: number; 30 | argName: string; 31 | }; 32 | 33 | export type NonPositionalWithValue = NonPositional & { 34 | argVal: string; 35 | }; 36 | 37 | export function eatNonPositionals( 38 | argNames: string[], 39 | argv: Argv, 40 | { 41 | howManyItemsToTakeWhenArgMatches = 1, // 42 | } = {} 43 | ): NonPositional[] { 44 | const argMatches = (idx: number) => argNames.includes(argv[idx]); 45 | let matchedArgIndexes: NonPositional["origIdx"][] = []; 46 | 47 | for (let i = 0; i < argv.length; i++) { 48 | if (argMatches(i)) { 49 | for (let j = 0; j < howManyItemsToTakeWhenArgMatches; j++) { 50 | matchedArgIndexes.push(i + j); 51 | } 52 | } 53 | } 54 | 55 | if (!matchedArgIndexes.length) { 56 | return []; 57 | } 58 | 59 | const nonPositionalsWithValues: NonPositional[] = []; 60 | for (const idx of matchedArgIndexes) { 61 | nonPositionalsWithValues.push({ 62 | origIdx: idx, 63 | argName: argv[idx], 64 | }); 65 | } 66 | 67 | const shouldRemoveArg = (idx: number) => matchedArgIndexes.includes(idx); 68 | const argvIndexesToRemove: number[] = []; 69 | 70 | for (let i = 0; i < argv.length; i++) { 71 | if (shouldRemoveArg(i)) { 72 | argvIndexesToRemove.push(i); 73 | } 74 | } 75 | 76 | removeArrayValuesAtIndices(argv, argvIndexesToRemove); 77 | 78 | return nonPositionalsWithValues; 79 | } 80 | 81 | export function eatNonPositionalsWithValues(argNames: string[], argv: Argv): NonPositionalWithValue[] { 82 | const argsWithTheirValueAsNextItem: NonPositional[] = eatNonPositionals(argNames, argv, { 83 | howManyItemsToTakeWhenArgMatches: 2, 84 | }); 85 | 86 | assert.deepStrictEqual(argsWithTheirValueAsNextItem.length % 2, 0, `expected all arguments to have a value.`); 87 | 88 | const properArgsWithValues: NonPositionalWithValue[] = []; 89 | for (let i = 0; i < argsWithTheirValueAsNextItem.length; i += 2) { 90 | const arg = argsWithTheirValueAsNextItem[i]; 91 | const val = argsWithTheirValueAsNextItem[i + 1]; 92 | 93 | properArgsWithValues.push({ 94 | origIdx: arg.origIdx, 95 | argName: arg.argName, 96 | argVal: val.argName, 97 | }); 98 | } 99 | 100 | return properArgsWithValues; 101 | } 102 | 103 | /** 104 | * internal utils 105 | */ 106 | 107 | export function removeArrayValuesAtIndices(arrayRef: T[], indexesToRemove: number[]): void { 108 | /** 109 | * go in reverse. 110 | * 111 | * because if went from 0 to length, 112 | * removing an item from the array would adjust all other indices, 113 | * which creates a mess & needs extra handling. 114 | */ 115 | const indexesBigToSmall = [...indexesToRemove].sort((A, B) => B - A); 116 | 117 | for (const idxToRemove of indexesBigToSmall) { 118 | arrayRef.splice(idxToRemove, 1); 119 | } 120 | 121 | return; 122 | } 123 | 124 | /** 125 | * common utilities for dealing w/ parsed values: 126 | */ 127 | 128 | export function maybe( 129 | x: T, // 130 | Some: (x: T) => S, 131 | None: (x?: never) => N 132 | ) { 133 | if (x instanceof Array) { 134 | return x.length ? Some(x) : None(); 135 | } 136 | 137 | return x !== undefined ? Some(x) : None(); 138 | } 139 | 140 | export const last = (xs: T[]): T => xs[xs.length - 1]; 141 | -------------------------------------------------------------------------------- /autosquash.ts: -------------------------------------------------------------------------------- 1 | /* eslint-disable indent */ 2 | 3 | import Git from "nodegit"; 4 | 5 | import { CommitAndBranchBoundary } from "./git-stacked-rebase"; 6 | 7 | import { Termination } from "./util/error"; 8 | import { assertNever } from "./util/assertNever"; 9 | 10 | /** 11 | * the general approach on how to handle autosquashing 12 | * is the following, in order: 13 | * 14 | * 1. collect your commits, 15 | * 2. extend them with branch boundaries, 16 | * 3. re-order the "fixup!" and "squash!" commits, 17 | * 4. convert from objects to strings that are joined 18 | * with a newline and written to the git-rebase-todo file 19 | * 20 | * 21 | * if we were to do (3) before (2) 22 | * (which is what happens if we would use git's native rebase 23 | * to collect the commits), 24 | * then, in a situation where a commit with a "fixup!" or "squash!" subject 25 | * is the latest commit of any branch in the stack, 26 | * that commit will move not only itself, but it's branch as well. 27 | * 28 | * we don't want that obviously - we instead want the branch 29 | * to point to a commit that was before the "fixup!" or "squash!" commit 30 | * (and same applies if there were multiple "fixup!" / "squash!" commits in a row). 31 | * 32 | * see the `--no-autosquash` enforcement/limitation in the 33 | * `getWantedCommitsWithBranchBoundariesUsingNativeGitRebase` function. 34 | * 35 | */ 36 | export async function autosquash( 37 | repo: Git.Repository, // 38 | extendedCommits: CommitAndBranchBoundary[] 39 | ): Promise { 40 | // type SHA = string; 41 | // const commitLookupTable: Map = new Map(); 42 | 43 | const autoSquashableSummaryPrefixes = ["squash!", "fixup!"] as const; 44 | 45 | /** 46 | * we want to re-order the commits, 47 | * but we do NOT want the branches to follow them. 48 | * 49 | * the easiest way to do this is to "un-attach" the branches from the commits, 50 | * do the re-ordering, 51 | * and then re-attach the branches to the new commits that are previous to the branch. 52 | */ 53 | const unattachedCommitsAndBranches: UnAttachedCommitOrBranch[] = unAttachBranchesFromCommits(extendedCommits); 54 | 55 | for (let i = 0; i < unattachedCommitsAndBranches.length; i++) { 56 | const commitOrBranch: UnAttachedCommitOrBranch = unattachedCommitsAndBranches[i]; 57 | 58 | if (isBranch(commitOrBranch)) { 59 | continue; 60 | } 61 | const commit: UnAttachedCommit = commitOrBranch; 62 | 63 | const summary: string = commit.commit.summary(); 64 | const hasAutoSquashablePrefix = (prefix: string): boolean => summary.startsWith(prefix); 65 | 66 | const autoSquashCommandIdx: number = autoSquashableSummaryPrefixes.findIndex(hasAutoSquashablePrefix); 67 | const shouldBeAutoSquashed = autoSquashCommandIdx !== -1; 68 | 69 | if (!shouldBeAutoSquashed) { 70 | continue; 71 | } 72 | 73 | const command = autoSquashableSummaryPrefixes[autoSquashCommandIdx]; 74 | const targetedCommittish: string = summary.split(" ")[1]; 75 | 76 | /** 77 | * https://libgit2.org/libgit2/#HEAD/group/revparse 78 | */ 79 | // Git.Revparse.ext(target, ) 80 | const target: Git.Object = await Git.Revparse.single(repo, targetedCommittish); 81 | const targetRev: Git.Object = await target.peel(Git.Object.TYPE.COMMIT); 82 | const targetType: number = await targetRev.type(); 83 | const targetIsCommit: boolean = targetType === Git.Object.TYPE.COMMIT; 84 | 85 | if (!targetIsCommit) { 86 | const msg = 87 | `\ntried to parse auto-squashable commit's target revision, but failed.` + 88 | `\ncommit = ${commit.commit.sha()} (${commit.commit.summary()})` + 89 | `\ncommand = ${command}` + 90 | `\ntarget = ${targetRev.id().tostrS()}` + 91 | `\ntarget type (expected ${Git.Object.TYPE.COMMIT}) = ${targetType}` + 92 | `\n\n`; 93 | 94 | throw new Termination(msg); 95 | } 96 | 97 | const indexOfTargetCommit: number = unattachedCommitsAndBranches.findIndex( 98 | (c) => !isBranch(c) && !target.id().cmp(c.commit.id()) 99 | ); 100 | const wasNotFound = indexOfTargetCommit === -1; 101 | 102 | if (wasNotFound) { 103 | const msg = 104 | `\ntried to re-order an auto-squashable commit, ` + 105 | `but the target commit was not within the commits that are being rebased.` + 106 | `\ncommit = ${commit.commit.sha()} (${commit.commit.summary()})` + 107 | `\ncommand = ${command}` + 108 | `\ntarget = ${targetRev.id().tostrS()}` + 109 | `\n\n`; 110 | 111 | throw new Termination(msg); 112 | } 113 | 114 | commit.commitCommand = 115 | command === "squash!" 116 | ? "squash" // 117 | : command === "fixup!" 118 | ? "fixup" 119 | : assertNever(command); 120 | 121 | /** 122 | * first remove the commit from the array, 123 | * and only then insert it in the array. 124 | * 125 | * this will always work, and the opposite will never work 126 | * because of index mismatch: 127 | * 128 | * you cannot reference commit SHAs that will appear in the future, 129 | * only in the past. 130 | * thus, we know that an auto-squashable commit's target will always be 131 | * earlier in the history than the auto-squashable commit itself. 132 | * 133 | * thus, we first remove the auto-squashable commit, 134 | * so that the index of the target commit stays the same, 135 | * and only then insert the auto-squashable commit. 136 | * 137 | * 138 | * TODO optimal implementation with a linked list + a map 139 | * 140 | */ 141 | unattachedCommitsAndBranches.splice(i, 1); // remove 1 element (`commit`) 142 | unattachedCommitsAndBranches.splice(indexOfTargetCommit + 1, 0, commit); // insert the `commit` in the new position 143 | } 144 | 145 | const reattached: CommitAndBranchBoundary[] = reAttachBranchesToCommits(unattachedCommitsAndBranches); 146 | 147 | return reattached; 148 | } 149 | 150 | type UnAttachedCommit = Omit; 151 | type UnAttachedBranch = Pick; 152 | type UnAttachedCommitOrBranch = UnAttachedCommit | UnAttachedBranch; 153 | 154 | function isBranch(commitOrBranch: UnAttachedCommitOrBranch): commitOrBranch is UnAttachedBranch { 155 | return "branchEnd" in commitOrBranch; 156 | } 157 | 158 | function unAttachBranchesFromCommits(attached: CommitAndBranchBoundary[]): UnAttachedCommitOrBranch[] { 159 | const unattached: UnAttachedCommitOrBranch[] = []; 160 | 161 | for (const { branchEnd, ...c } of attached) { 162 | unattached.push(c); 163 | 164 | if (branchEnd?.length) { 165 | unattached.push({ branchEnd }); 166 | } 167 | } 168 | 169 | return unattached; 170 | } 171 | 172 | /** 173 | * the key to remember here is that commits could've been moved around 174 | * (that's the whole purpose of unattaching and reattaching the branches) 175 | * (specifically, commits can only be moved back in history, 176 | * because you cannot specify a SHA of a commit in the future), 177 | * 178 | * and thus multiple `branchEnd` could end up pointing to a single commit, 179 | * which just needs to be handled. 180 | * 181 | */ 182 | function reAttachBranchesToCommits(unattached: UnAttachedCommitOrBranch[]): CommitAndBranchBoundary[] { 183 | const reattached: CommitAndBranchBoundary[] = []; 184 | 185 | let branchEndsForCommit: NonNullable[] = []; 186 | 187 | for (let i = unattached.length - 1; i >= 0; i--) { 188 | const commitOrBranch = unattached[i]; 189 | 190 | if (isBranch(commitOrBranch) && commitOrBranch.branchEnd?.length) { 191 | /** 192 | * it's a branchEnd. remember the above consideration 193 | * that multiple of them can accumulate for a single commit, 194 | * thus buffer them, until we reach a commit. 195 | */ 196 | branchEndsForCommit.push(commitOrBranch.branchEnd); 197 | } else { 198 | /** 199 | * we reached a commit. 200 | */ 201 | 202 | let combinedBranchEnds: NonNullable = []; 203 | 204 | /** 205 | * they are added in reverse order (i--). let's reverse branchEndsForCommit 206 | */ 207 | for (let j = branchEndsForCommit.length - 1; j >= 0; j--) { 208 | const branchEnd: Git.Reference[] = branchEndsForCommit[j]; 209 | combinedBranchEnds = combinedBranchEnds.concat(branchEnd); 210 | } 211 | 212 | const restoredCommitWithBranchEnds: CommitAndBranchBoundary = { 213 | ...(commitOrBranch as UnAttachedCommit), // TODO TS assert 214 | branchEnd: [...combinedBranchEnds], 215 | }; 216 | 217 | reattached.push(restoredCommitWithBranchEnds); 218 | branchEndsForCommit = []; 219 | } 220 | } 221 | 222 | /** 223 | * we were going backwards - restore correct order. 224 | * reverses in place. 225 | */ 226 | reattached.reverse(); 227 | 228 | if (branchEndsForCommit.length) { 229 | /** 230 | * TODO should never happen, 231 | * or we should assign by default to the 1st commit 232 | */ 233 | 234 | const msg = 235 | `\nhave leftover branches without a commit to attach onto:` + 236 | `\n${branchEndsForCommit.join("\n")}` + 237 | `\n\n`; 238 | 239 | throw new Termination(msg); 240 | } 241 | 242 | return reattached; 243 | } 244 | -------------------------------------------------------------------------------- /branchSequencer.ts: -------------------------------------------------------------------------------- 1 | import fs from "fs"; 2 | import assert from "assert"; 3 | import os from "os"; 4 | 5 | import Git from "nodegit"; 6 | 7 | import { CommitAndBranchBoundary, getWantedCommitsWithBranchBoundariesOurCustomImpl, removeLocalRegex, removeRemoteRegex } from "./git-stacked-rebase"; 8 | 9 | import { createExecSyncInRepo } from "./util/execSyncInRepo"; 10 | import { Termination } from "./util/error"; 11 | import { assertNever } from "./util/assertNever"; 12 | import { logAlways } from "./util/log"; 13 | 14 | import { parseNewGoodCommands } from "./parse-todo-of-stacked-rebase/parseNewGoodCommands"; 15 | import { GoodCommand, GoodCommandStacked } from "./parse-todo-of-stacked-rebase/validator"; 16 | 17 | export type BranchRefs = { 18 | initialBranch: Git.Reference; 19 | currentBranch: Git.Reference; 20 | }; 21 | 22 | export type GetBranchesCtx = BranchRefs & { 23 | pathToStackedRebaseDirInsideDotGit: string; 24 | rootLevelCommandName: string; 25 | repo: Git.Repository; 26 | pathToStackedRebaseTodoFile: string; 27 | }; 28 | 29 | export type SimpleBranchAndCommit = { 30 | commitSHA: string | null; 31 | branchEndFullName: string[]; 32 | // branchExistsYet: boolean; // TODO 33 | }; 34 | 35 | export type GetBoundariesInclInitial = ( 36 | ctx: GetBranchesCtx // 37 | ) => SimpleBranchAndCommit[] | Promise; 38 | 39 | export const isStackedRebaseInProgress = ({ 40 | pathToStackedRebaseDirInsideDotGit, 41 | }: { 42 | pathToStackedRebaseDirInsideDotGit: string; 43 | }): boolean => fs.existsSync(pathToStackedRebaseDirInsideDotGit); 44 | 45 | export const getBoundariesInclInitialByParsingNotYetAppliedState: GetBoundariesInclInitial = ({ 46 | pathToStackedRebaseDirInsideDotGit, // 47 | rootLevelCommandName, 48 | repo, 49 | pathToStackedRebaseTodoFile, 50 | }) => { 51 | /** 52 | * TODO REMOVE / modify this logic (see next comment) 53 | */ 54 | if (!isStackedRebaseInProgress({ pathToStackedRebaseDirInsideDotGit })) { 55 | throw new Termination(`\n\nno stacked-rebase in progress? (nothing to ${rootLevelCommandName})\n\n`); 56 | } 57 | // const hasPostRewriteHookInstalledWithLatestVersion = false; 58 | 59 | /** 60 | * 61 | * this is only needed to get the branch names. 62 | * 63 | * we should instead have this as a function in the options, 64 | * we should provide the default value, 65 | * but allow the higher level command to overwrite it. 66 | * 67 | * use case differences: 68 | * 69 | * a) apply: 70 | * 71 | * needs (always or no?) to parse the new good commands 72 | * 73 | * b) push: 74 | * 75 | * since is only allowed after apply has been done, 76 | * it doesn't actually care nor need to parse the new good commands, 77 | * and instead can be done by simply going thru the branches 78 | * that you would normally do with `getWantedCommitsWithBranchBoundaries`. 79 | * 80 | * and so it can be used even if the user has never previously used stacked rebase! 81 | * all is needed is the `initialBranch` and the current commit, 82 | * so that we find all the previous branches up until `initialBranch` 83 | * and just push them! 84 | * 85 | * and this is safe because again, if there's something that needs to be applied, 86 | * then before you can push, you'll need to apply first. 87 | * 88 | * otherwise, you can push w/o any need of apply, 89 | * or setting up the intial rebase todo, or whatever else, 90 | * because it's not needed! 91 | * 92 | * --- 93 | * 94 | * this is also good because we become less stateful / need less state 95 | * to function properly. 96 | * 97 | * it very well could get rid of some bugs / impossible states 98 | * that we'd sometimes end up in. 99 | * (and no longer need to manually rm -rf .git/stacked-rebase either) 100 | * 101 | */ 102 | const stackedRebaseCommandsNew: GoodCommand[] = parseNewGoodCommands(repo, pathToStackedRebaseTodoFile); 103 | 104 | for (const cmd of stackedRebaseCommandsNew) { 105 | assert(cmd.rebaseKind === "stacked"); 106 | assert(cmd.targets?.length); 107 | } 108 | 109 | return (stackedRebaseCommandsNew // 110 | .filter((cmd) => cmd.rebaseKind === "stacked") as GoodCommandStacked[]) // 111 | .map( 112 | (cmd): SimpleBranchAndCommit => ({ 113 | commitSHA: cmd.commitSHAThatBranchPointsTo, 114 | branchEndFullName: [cmd.targets![0]], 115 | }) 116 | ); 117 | }; 118 | 119 | export const getBoundariesInclInitialWithSipleBranchTraversal: GetBoundariesInclInitial = (argsBase) => 120 | getWantedCommitsWithBranchBoundariesOurCustomImpl( 121 | argsBase.repo, // 122 | argsBase.initialBranch, 123 | argsBase.currentBranch 124 | ).then((boundaries) => convertBoundaryToSimpleBranchAndCommit(boundaries)); 125 | 126 | export function convertBoundaryToSimpleBranchAndCommit(boundaries: CommitAndBranchBoundary[]): SimpleBranchAndCommit[] { 127 | return boundaries 128 | .filter((b) => !!b.branchEnd?.length) 129 | .map( 130 | (boundary): SimpleBranchAndCommit => ({ 131 | branchEndFullName: boundary.branchEnd!.map((x) => x.name()), // TS ok because of the filter 132 | commitSHA: boundary.commit.sha(), 133 | }) 134 | ) 135 | } 136 | 137 | /** 138 | * not sure if i'm a fan of this indirection tbh.. 139 | */ 140 | export enum BehaviorOfGetBranchBoundaries { 141 | /** 142 | * the default one. 143 | */ 144 | "parse-from-not-yet-applied-state", 145 | /** 146 | * originally used by `--push` - since it wouldn't be allowed to run 147 | * before `--apply` was used, 148 | * having to sync from applied state was more confusion & limiting. 149 | * 150 | * further, we later got rid of the state after `--apply`ing, 151 | * so this became the only option anyway. 152 | */ 153 | "ignore-unapplied-state-and-use-simple-branch-traversal", 154 | /** 155 | * this is the adaptive of the other 2. 156 | * originally intended for `branchSequencerExec` (`--exec`) - 157 | * we don't know what's coming from the user, 158 | * so we cannot make any assumptions. 159 | * 160 | * instead, we simply check if a stacked rebase (our) is in progress, 161 | * if so - we use the 1st option (because we have to), 162 | * otherwise - the 2nd option (because we have to, too!). 163 | */ 164 | "if-stacked-rebase-in-progress-then-parse-not-applied-state-otherwise-simple-branch-traverse", 165 | } 166 | export const defaultGetBranchBoundariesBehavior = 167 | BehaviorOfGetBranchBoundaries[ 168 | "if-stacked-rebase-in-progress-then-parse-not-applied-state-otherwise-simple-branch-traverse" 169 | ]; 170 | 171 | export const pickBoundaryParser = ({ 172 | behaviorOfGetBranchBoundaries, 173 | pathToStackedRebaseDirInsideDotGit, 174 | }: { 175 | /** 176 | * can provide one of the predefined behaviors, 177 | * whom will decide which parser to pick, 178 | * 179 | * or can provide a custom parser function. 180 | */ 181 | behaviorOfGetBranchBoundaries: BehaviorOfGetBranchBoundaries | GetBoundariesInclInitial; 182 | pathToStackedRebaseDirInsideDotGit: string; 183 | }): GetBoundariesInclInitial => { 184 | if (typeof behaviorOfGetBranchBoundaries === "function") { 185 | /** 186 | * custom fn 187 | */ 188 | return behaviorOfGetBranchBoundaries; 189 | } else if (behaviorOfGetBranchBoundaries === BehaviorOfGetBranchBoundaries["parse-from-not-yet-applied-state"]) { 190 | return getBoundariesInclInitialByParsingNotYetAppliedState; 191 | } else if ( 192 | behaviorOfGetBranchBoundaries === 193 | BehaviorOfGetBranchBoundaries["ignore-unapplied-state-and-use-simple-branch-traversal"] 194 | ) { 195 | return getBoundariesInclInitialWithSipleBranchTraversal; 196 | } else if ( 197 | behaviorOfGetBranchBoundaries === 198 | BehaviorOfGetBranchBoundaries[ 199 | "if-stacked-rebase-in-progress-then-parse-not-applied-state-otherwise-simple-branch-traverse" 200 | ] 201 | ) { 202 | if (isStackedRebaseInProgress({ pathToStackedRebaseDirInsideDotGit })) { 203 | return getBoundariesInclInitialByParsingNotYetAppliedState; 204 | } else { 205 | return getBoundariesInclInitialWithSipleBranchTraversal; 206 | } 207 | } else { 208 | assertNever(behaviorOfGetBranchBoundaries); 209 | } 210 | }; 211 | 212 | /** 213 | * 214 | */ 215 | 216 | export type ActionInsideEachCheckedOutBranchCtx = { 217 | repo: Git.Repository; // 218 | gitCmd: string; 219 | targetBranch: string; 220 | targetCommitSHA: string; 221 | isLatestBranch: boolean; 222 | execSyncInRepo: ReturnType; 223 | 224 | collectError: (e: unknown) => void; 225 | }; 226 | export type ActionInsideEachCheckedOutBranch = (ctx: ActionInsideEachCheckedOutBranchCtx) => void | Promise; 227 | 228 | export type BranchSequencerArgsBase = BranchRefs & { 229 | pathToStackedRebaseDirInsideDotGit: string; // 230 | // goodCommands: GoodCommand[]; 231 | pathToStackedRebaseTodoFile: string; 232 | repo: Git.Repository; 233 | rootLevelCommandName: string; 234 | gitCmd: string; 235 | }; 236 | 237 | export type BranchSequencerArgs = BranchSequencerArgsBase & { 238 | // callbackBeforeBegin?: CallbackAfterDone; // TODO 239 | actionInsideEachCheckedOutBranch: ActionInsideEachCheckedOutBranch; 240 | delayMsBetweenCheckouts?: number; 241 | behaviorOfGetBranchBoundaries?: Parameters[0]["behaviorOfGetBranchBoundaries"]; 242 | 243 | /** 244 | * normally, you checkout to the 1st partial branch in the stack, 245 | * then the 2nd, etc, up until you reach the latest branch. 246 | * 247 | * use `reverseCheckoutOrder` to do the opposite. 248 | * 249 | */ 250 | reverseCheckoutOrder: boolean; 251 | }; 252 | 253 | export type BranchSequencerBase = (args: BranchSequencerArgsBase) => Promise; 254 | export type BranchSequencer = (args: BranchSequencerArgs) => Promise; 255 | 256 | export const branchSequencer: BranchSequencer = async ({ 257 | pathToStackedRebaseDirInsideDotGit, // 258 | pathToStackedRebaseTodoFile, 259 | repo, 260 | rootLevelCommandName, 261 | delayMsBetweenCheckouts = 0, 262 | // callbackBeforeBegin, 263 | actionInsideEachCheckedOutBranch, 264 | gitCmd, 265 | // 266 | behaviorOfGetBranchBoundaries = defaultGetBranchBoundariesBehavior, 267 | initialBranch, 268 | currentBranch, 269 | // 270 | reverseCheckoutOrder = false, 271 | }) => { 272 | const execSyncInRepo = createExecSyncInRepo(repo.workdir()); 273 | 274 | const getBoundariesInclInitial: GetBoundariesInclInitial = pickBoundaryParser({ 275 | behaviorOfGetBranchBoundaries, 276 | pathToStackedRebaseDirInsideDotGit, 277 | }); 278 | 279 | const branchesAndCommits: SimpleBranchAndCommit[] = ( 280 | await getBoundariesInclInitial({ 281 | pathToStackedRebaseDirInsideDotGit, 282 | pathToStackedRebaseTodoFile, 283 | repo, 284 | rootLevelCommandName, 285 | initialBranch, 286 | currentBranch, 287 | }) 288 | ).map(trimRefNamesOfBoundary); 289 | 290 | /** 291 | * remove the initial branch 292 | */ 293 | branchesAndCommits.shift(); 294 | 295 | const originalBoundariesLength: number = branchesAndCommits.length; 296 | 297 | if (reverseCheckoutOrder) { 298 | branchesAndCommits.reverse(); 299 | } 300 | 301 | const switchBackToLatestBranch = (msgCb?: (latestBranch: string) => string) => { 302 | const latestBranch: string = currentBranch.shorthand(); 303 | 304 | if (msgCb && msgCb instanceof Function) { 305 | process.stdout.write(msgCb(latestBranch)); 306 | } 307 | 308 | execSyncInRepo(`${gitCmd} checkout ${latestBranch}`); 309 | }; 310 | 311 | /** 312 | * if aborted, switch back to latest branch. 313 | */ 314 | function handleSigint() { 315 | switchBackToLatestBranch((latestBranch) => `\ncaught SIGINT, switching back to latest branch '${latestBranch}'\n`); 316 | process.exit(128 + os.constants.signals.SIGINT); 317 | } 318 | function handleSigterm() { 319 | switchBackToLatestBranch((latestBranch) => `\ncaught SIGTERM, switching back to latest branch '${latestBranch}'\n`); 320 | process.exit(128 + os.constants.signals.SIGTERM); 321 | } 322 | 323 | const collectedErrors: unknown[] = []; 324 | 325 | /** 326 | * node process may exit because of SIGINT/SIGTERM, 327 | * but before it can - an `UnhandledPromiseRejectionWarning` can occur, 328 | * which prints an ugly warning, and we don't want that, 329 | * since it most oftenly occurs only because of the SIGTERM (Ctrl-C), 330 | * which e.g. in forcePush interrupts the executed git child process. 331 | * 332 | * in such a case, we don't care about the warning, 333 | * and want to exit calmly. 334 | * 335 | * thus, we collect the errors, and only throw them 336 | * after the performing the action on all branches. 337 | * obviously, we don't throw if the node process already exited. 338 | */ 339 | function collectError(e: unknown) { 340 | collectedErrors.push(e); 341 | } 342 | 343 | /** add listeners */ 344 | process.on("SIGINT", handleSigint); 345 | process.on("SIGTERM", handleSigterm); 346 | 347 | return checkout(branchesAndCommits).finally(() => { 348 | /** remove listeners */ 349 | process.removeListener("SIGINT", handleSigint); 350 | process.removeListener("SIGTERM", handleSigterm); 351 | 352 | if (collectedErrors.length) { 353 | console.error(collectedErrors); 354 | 355 | const msg = `encountered ${collectedErrors.length} errors, aborting.`; 356 | throw new Error(msg); 357 | } 358 | }); 359 | 360 | async function checkout(boundaries: SimpleBranchAndCommit[]): Promise { 361 | if (!boundaries.length) { 362 | /** done */ 363 | switchBackToLatestBranch(); 364 | return; 365 | } 366 | 367 | logAlways("\ncheckout", boundaries.length, reverseCheckoutOrder ? "(reversed)" : ""); 368 | 369 | const goNext = () => 370 | new Promise((r) => { 371 | setTimeout(() => { 372 | checkout(boundaries.slice(1)).then(() => r()); 373 | }, delayMsBetweenCheckouts); 374 | }); 375 | 376 | const boundary = boundaries[0]; 377 | const targetBranch = boundary.branchEndFullName; 378 | const targetCommitSHA: string | null = boundary.commitSHA; 379 | 380 | if (!targetCommitSHA) { 381 | return goNext(); 382 | } 383 | 384 | const isLatestBranch: boolean = reverseCheckoutOrder 385 | ? boundaries.length === originalBoundariesLength 386 | : boundaries.length === 1; 387 | 388 | for (const target of targetBranch) { 389 | /** 390 | * https://libgit2.org/libgit2/#HEAD/group/checkout/git_checkout_head 391 | */ 392 | // await Git.Checkout.tree(repo, targetBranch as any); // TODO TS FIXME 393 | execSyncInRepo(`${gitCmd} checkout ${target}`); // f this 394 | 395 | await actionInsideEachCheckedOutBranch({ 396 | repo, // 397 | gitCmd, 398 | targetBranch: target, 399 | targetCommitSHA, 400 | isLatestBranch, 401 | execSyncInRepo, 402 | collectError, 403 | }); 404 | } 405 | 406 | return goNext(); 407 | } 408 | }; 409 | 410 | export function trimRefNamesOfBoundary(boundary: SimpleBranchAndCommit) { 411 | boundary.branchEndFullName = boundary.branchEndFullName.map((x) => x.replace(removeLocalRegex, "")); 412 | assert(boundary.branchEndFullName); 413 | 414 | /** 415 | * if we only have the remote branch, but it's not checked out locally, 416 | * we'd end up in a detached state, and things would break. 417 | * 418 | * thus, we checkout the branch locally if it's not. 419 | */ 420 | if (boundary.branchEndFullName.some((x) => x.startsWith("refs/remotes/"))) { 421 | boundary.branchEndFullName = boundary.branchEndFullName.map((x) => x.replace(removeRemoteRegex, "")); 422 | } 423 | 424 | return boundary; 425 | } 426 | -------------------------------------------------------------------------------- /config.ts: -------------------------------------------------------------------------------- 1 | /* eslint-disable @typescript-eslint/camelcase */ 2 | 3 | import Git from "nodegit"; 4 | 5 | import { SpecifiableGitStackedRebaseOptions } from "./options"; 6 | import { getGitConfig__internal } from "./internal"; 7 | 8 | import { nativeConfigGet } from "./native-git/config"; 9 | 10 | export const configKeyPrefix = "stackedrebase" as const; 11 | 12 | export type ConfigKeys = typeof configKeys; 13 | export type ConfigKey = keyof ConfigKeys; 14 | 15 | export const configKeys = { 16 | gpgSign: "commit.gpgSign", 17 | autoApplyIfNeeded: `${configKeyPrefix}.autoApplyIfNeeded`, 18 | autoSquash: "rebase.autoSquash", 19 | autoOpenPRUrlsInBrowser: `${configKeyPrefix}.autoOpenPRUrlsInBrowser`, 20 | ignoredBranches: `${configKeyPrefix}.ignoredBranches`, 21 | } as const; 22 | 23 | export async function loadGitConfig( 24 | repo: Git.Repository, 25 | specifiedOptions: SpecifiableGitStackedRebaseOptions 26 | ): Promise { 27 | return getGitConfig__internal in specifiedOptions 28 | ? await specifiedOptions[getGitConfig__internal]!({ GitConfig: Git.Config, repo }) 29 | : await Git.Config.openDefault(); 30 | } 31 | 32 | /** 33 | * DON'T FORGET TO UPDATE `_BaseOptionsForGitStackedRebase_Optional` in options.ts 34 | */ 35 | export type ConfigValues = { 36 | gpgSign: boolean | undefined; 37 | autoApplyIfNeeded: boolean | undefined; 38 | autoSquash: boolean | undefined; 39 | autoOpenPRUrlsInBrowser: "always" | "ask" | "never"; 40 | 41 | /** 42 | * 43 | * @EXPERIMENTAL 44 | * currently only matters for `getStackedBranchesReadyForStackedPRs`; 45 | * in future should be expanded to work across all GSR operations. 46 | * 47 | * --- 48 | * 49 | * branches to ignore in the stack. 50 | * 51 | * should be specified before invoking git-stacked-rebase; 52 | * specifying when in progress / in between commands can cause undefined behavior. 53 | * 54 | * matches substrings, e.g. if `night` is specified, 55 | * `nightly` will match & will be ignored. 56 | */ 57 | ignoredBranches: string[]; 58 | }; 59 | 60 | export const defaultConfigValues: Pick = { 61 | autoOpenPRUrlsInBrowser: "ask", 62 | }; // TODO TS satisfies ConfigValues 63 | 64 | export async function resolveGitConfigValues(config: Git.Config): Promise { 65 | /** 66 | * beware: 67 | * libgit's Git.Config is always taking from global, and ignoring local, 68 | * which is obviously incorrect and not what we want... 69 | * 70 | * this (mostly) does not matter, until a user wants to configure per-project settings, 71 | * which wasn't needed much, until `ignoredBranches` got implemented... 72 | */ 73 | const [ 74 | gpgSign, // 75 | autoApplyIfNeeded, 76 | autoSquash, 77 | autoOpenPRUrlsInBrowser, 78 | ignoredBranches, 79 | ] = await Promise.all([ 80 | resolveConfigBooleanValue(config.getBool(configKeys.gpgSign)), 81 | resolveConfigBooleanValue(config.getBool(configKeys.autoApplyIfNeeded)), 82 | resolveConfigBooleanValue(config.getBool(configKeys.autoSquash)), 83 | resolveAutoOpenPRUrlsInBrowserValue(config.getStringBuf(configKeys.autoOpenPRUrlsInBrowser)), 84 | resolveConfigArrayValue(nativeConfigGet(configKeys.ignoredBranches)), 85 | ]); 86 | 87 | const configValues: ConfigValues = { 88 | gpgSign, 89 | autoApplyIfNeeded, 90 | autoSquash, 91 | autoOpenPRUrlsInBrowser, 92 | ignoredBranches, 93 | }; 94 | 95 | return configValues; 96 | } 97 | 98 | /** 99 | * there's a difference between a value set to false (intentionally disabled), 100 | * vs not set at all: 101 | * can then look thru lower level options providers, and take their value. 102 | * 103 | * ``` 104 | * export const handleConfigBooleanValue = (x: Promise) => x.then(Boolean).catch(() => undefined); 105 | * ``` 106 | * 107 | * but actually, it doesn't matter here, because when we're trying to resolve (here), 108 | * our goal is to provide a final value that will be used by the program, 109 | * thus no `undefined`. 110 | * 111 | */ 112 | // 113 | export const resolveConfigBooleanValue = (x: Promise) => x.then(Boolean).catch(() => false); 114 | 115 | export const resolveConfigArrayValue = (x: Promise): Promise => 116 | x.then((x) => x.split(",")).catch(() => []); 117 | 118 | export const resolveAutoOpenPRUrlsInBrowserValue = ( 119 | pendingConfigValue: Promise 120 | ): Promise => { 121 | const parse = (x: Git.Buf) => 122 | autoOpenPRUrlsInBrowserAllowedValues.includes(x.ptr as ConfigValues["autoOpenPRUrlsInBrowser"]) 123 | ? (x.ptr as ConfigValues["autoOpenPRUrlsInBrowser"]) 124 | : defaultConfigValues.autoOpenPRUrlsInBrowser; 125 | 126 | return pendingConfigValue 127 | .then(parse) // 128 | .catch(() => defaultConfigValues.autoOpenPRUrlsInBrowser); 129 | }; 130 | export const autoOpenPRUrlsInBrowserAllowedValues: ConfigValues["autoOpenPRUrlsInBrowser"][] = [ 131 | "always", 132 | "ask", 133 | "never", 134 | ]; 135 | -------------------------------------------------------------------------------- /filenames.ts: -------------------------------------------------------------------------------- 1 | /** 2 | * both their & ours. 3 | */ 4 | export const filenames = { 5 | rewrittenList: "rewritten-list", 6 | willNeedToApply: "will-need-to-apply", 7 | needsToApply: "needs-to-apply", 8 | applied: "rewritten-list.applied", 9 | // 10 | gitRebaseTodo: "git-rebase-todo", 11 | // 12 | postStackedRebaseHook: "post-stacked-rebase", 13 | // 14 | initialBranch: "initial-branch", 15 | 16 | /** 17 | * TODO extract others into here 18 | */ 19 | } as const; 20 | -------------------------------------------------------------------------------- /forcePush.ts: -------------------------------------------------------------------------------- 1 | /* eslint-disable indent */ 2 | 3 | // import fs from "fs"; 4 | 5 | import Git from "nodegit"; 6 | 7 | import { 8 | BehaviorOfGetBranchBoundaries, 9 | branchSequencer, // 10 | BranchSequencerBase, 11 | // getBackupPathOfPreviousStackedRebase, 12 | } from "./branchSequencer"; 13 | 14 | import { createQuestion } from "./util/createQuestion"; 15 | import { Termination } from "./util/error"; 16 | import { log } from "./util/log"; 17 | 18 | export const forcePush: BranchSequencerBase = (argsBase) => 19 | // /** 20 | // * TODO TESTS we __really__ need to make sure we test this one lmao 21 | // */ 22 | // let pathToStackedRebaseDirInsideDotGit: string; 23 | 24 | // if (fs.existsSync(argsBase.pathToStackedRebaseDirInsideDotGit)) { 25 | // pathToStackedRebaseDirInsideDotGit = argsBase.pathToStackedRebaseDirInsideDotGit; 26 | // } else { 27 | // const previous = getBackupPathOfPreviousStackedRebase(argsBase.pathToStackedRebaseDirInsideDotGit); 28 | 29 | // // if (!fs.existsSync(previous)) { 30 | // // } 31 | 32 | // /** 33 | // * attempt to continue w/ the latest rebase that happened. 34 | // * 35 | // * if folder not found, branchSequencer should handle it 36 | // * the same way as it would've handled the folder from argsBase. 37 | // */ 38 | // pathToStackedRebaseDirInsideDotGit = previous; 39 | // } 40 | 41 | branchSequencer({ 42 | ...argsBase, 43 | // pathToStackedRebaseDirInsideDotGit, 44 | actionInsideEachCheckedOutBranch: async ({ execSyncInRepo, repo, collectError }) => { 45 | const branch: Git.Reference = await repo.getCurrentBranch(); 46 | const upstreamBranch: Git.Reference | null = await Git.Branch.upstream(branch).catch(() => null); 47 | 48 | /** 49 | * TODO work out a good solution because we don't want the user 50 | * to get interrupted while in-between the "push" flow, 51 | * or at least handle it ourselves / ask the user how to continue 52 | * 53 | * maybe need to have a `--push --continue`? 54 | * ugh, starts to get mixed up w/ other commands, idk! 55 | * or could `--continue` be used in any circumstance, 56 | * i.e. both in a rebase setting, and in a push setting? 57 | * 58 | * could maybe utilize --dry-run? or analyze ourselves? idk 59 | * 60 | * needs to be further explored with our `--sync` (TBD) 61 | * 62 | * 63 | * on --force-if-includes, see: 64 | * - `man git-push` 65 | * - https://stackoverflow.com/a/65839129/9285308 66 | * - https://github.com/gitextensions/gitextensions/issues/8753#issuecomment-763390579 67 | */ 68 | const forceWithLeaseOrForce: string = "--force-with-lease --force-if-includes"; 69 | 70 | try { 71 | if (!upstreamBranch) { 72 | let remote: string = await pickRemoteFromRepo(repo, { 73 | cannotDoXWhenZero: "Cannot push a new branch into a remote.", 74 | pleaseChooseOneFor: "new branch", 75 | }); 76 | 77 | const cmd = `push -u ${remote} ${branch.name()} ${forceWithLeaseOrForce}`; 78 | log(`running ${cmd}`); 79 | execSyncInRepo(`${argsBase.gitCmd} ${cmd}`); 80 | } else { 81 | execSyncInRepo(`${argsBase.gitCmd} push ${forceWithLeaseOrForce}`); 82 | } 83 | } catch (e) { 84 | collectError(e); 85 | } 86 | }, 87 | delayMsBetweenCheckouts: 0, 88 | 89 | /** 90 | * `--push` should not be allowed if `--apply` has not ran yet, 91 | * thus this is the desired behavior. 92 | */ 93 | behaviorOfGetBranchBoundaries: 94 | BehaviorOfGetBranchBoundaries["ignore-unapplied-state-and-use-simple-branch-traversal"], 95 | 96 | /** 97 | * (experimental) 98 | * 99 | * github just closed (emptily merged) one of the PRs 100 | * because some commit was now already part of some other branch. 101 | * 102 | * pretty sure that pushing newest branches first, and oldest later, 103 | * instead of the current "oldest first, then newest", 104 | * would solve this. 105 | */ 106 | reverseCheckoutOrder: true, 107 | }); 108 | 109 | export async function pickRemoteFromRepo( 110 | repo: Git.Repository, 111 | { 112 | cannotDoXWhenZero, 113 | pleaseChooseOneFor, 114 | }: { 115 | cannotDoXWhenZero?: string; // 116 | pleaseChooseOneFor?: string; 117 | } 118 | ) { 119 | const remotes: string[] = await repo.getRemoteNames(); 120 | 121 | if (remotes.length === 0) { 122 | const msg = "0 remotes found." + (!cannotDoXWhenZero ? "" : " " + cannotDoXWhenZero); 123 | throw new Termination(msg); 124 | } 125 | 126 | let remote: string; 127 | 128 | if (remotes.length === 1) { 129 | remote = remotes[0]; 130 | } else { 131 | const indices: string[] = remotes.map((_, i) => i + 1).map((x) => x.toString()); 132 | 133 | const question = createQuestion(); 134 | 135 | let answer: string = ""; 136 | 137 | let first = true; 138 | while (!remotes.includes(answer)) { 139 | const q: string = [ 140 | first ? "\n" : "", 141 | `multiple remotes detected, please choose one` + !pleaseChooseOneFor ? "." : " for" + pleaseChooseOneFor + ":", 142 | remotes.map((r, i) => ` ${i + 1} ${r}`).join("\n"), 143 | "> ", 144 | ].join("\n"); 145 | 146 | answer = (await question(q)).trim().toLowerCase(); 147 | 148 | if (indices.includes(answer)) { 149 | answer = remotes[Number(answer) - 1]; 150 | } 151 | 152 | first = false; 153 | } 154 | 155 | remote = answer; 156 | } 157 | 158 | return remote; 159 | } 160 | -------------------------------------------------------------------------------- /git-reconcile-rewritten-list/README.md: -------------------------------------------------------------------------------- 1 | # git-reconcile-rewritten-list 2 | 3 | extracted from [git-stacked-rebase](../). 4 | 5 | after `git rebase`, `git commit --amend`, etc., especially multiple ones, the rewritten-list will not tell the full story. 6 | 7 | `git-reconcile-rewritten-list` currently provides 2 methods: 8 | 9 | - [setupPostRewriteHookFor](./postRewriteHook.ts) 10 | - to setup custom `post-rewrite` hook(s), so that the full story gets captured 11 | - [combineRewrittenLists](./combineRewrittenLists.ts) 12 | - to "combine" the rewritten lists, or rather: for each rebase - to normalize it & it's amends 13 | 14 | there's some logic in [git-stacked-rebase](../), specifically `parseNewGoodCommands` & friends, yet to be extracted. 15 | (if ever? since it's very related to git-stacked-rebase itself. not sure where the boundary is yet). 16 | 17 | note that a proper solution for combining all rewritten lists is still yet to be implemented. 18 | - [git-stacked-rebase](../) uses the `combinedRewrittenList`, which currently works by taking the last rewritten list. 19 | - we might eventually discover that combining all rewritten lists as a single operation & only then processing them 20 | is actually not giving us the correct results: git-stacked-rebase needs to recover branches 21 | from the very first append into the `rewritten-list` file, up until it's called again to `--apply`. 22 | what could happen is that a user would make updates to branches in between their multiple regular rebases 23 | (without using git-stacked-rebase, which would itself `--apply` before doing a new rebase), 24 | - e.g. commit `A` gets rewritten to `A1` and later `A2`. branch `B` used to point to `A`, 25 | but gets changed by the user to `A1`. 26 | we'd need to point the branch to `A2`, but we have no knowledge of it being changed to `A1` -- 27 | we only see that `A` went to `A2` when we combine the rewritten lists, 28 | but we don't pick up anything about `A1`, and thus `B` gets lost. 29 | (that's been the whole point of combining so far. well, not for rebases, but for amends). 30 | - to fix this problem, instead of fully normalizing & only then resolving the rebase events, 31 | which leaves us with only the 1st & last states, 32 | we'd need to do the resolving in between each step of normalization, to make sure that no history is lost. 33 | - but, again, we haven't tested yet if this problem actually occurs. though it probably does. 34 | my main testing ground is me myself dogfooding the tool, and i've never encountered such a situation 35 | (i'd have to intentionally try to get into it). so to test the hypothesis, need to create a test case for it first. 36 | - perhaps this is why i wanted to & indeed did name this sub-project `reconcile` instead of `combine`. 37 | -------------------------------------------------------------------------------- /git-reconcile-rewritten-list/combineRewrittenLists.ts: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env ts-node-dev 2 | 3 | /* eslint-disable */ 4 | 5 | import assert from "assert" 6 | import fs from "fs" 7 | import { execSync } from "child_process" 8 | 9 | import { log} from "../util/log" 10 | 11 | export type StringFromToMap = { [key: string]: string } 12 | 13 | export type RewrittenListBlockBase = { 14 | mapping: StringFromToMap 15 | } 16 | export type RewrittenListBlockAmend = RewrittenListBlockBase & { 17 | type: "amend" 18 | } 19 | export type RewrittenListBlockRebase = RewrittenListBlockBase & { 20 | type: "rebase" 21 | } 22 | export type RewrittenListBlock = RewrittenListBlockAmend | RewrittenListBlockRebase 23 | 24 | export type CombineRewrittenListsRet = { 25 | /** 26 | * notice that this only includes rebases, no amends -- 27 | * that's the whole point. 28 | * 29 | * further, probably only the 1st one is necessary, 30 | * because it's likely that we'll start creating separate files for new rebases, 31 | * or that might not be needed at all, because we might be able to 32 | * --apply after every rebase, no matter if the user exited or not, 33 | * thus we'd always have only 1 "rebase" block in the rewritten list. 34 | */ 35 | mergedReducedRewrittenLists: RewrittenListBlockRebase[], 36 | 37 | /** 38 | * the git's standard represantation of the rewritten-list 39 | * (no extras of ours) 40 | */ 41 | combinedRewrittenList: string, 42 | } 43 | 44 | export function combineRewrittenLists(rewrittenListFileContent: string): CombineRewrittenListsRet { 45 | /** 46 | * $1 (amend/rebase) 47 | */ 48 | const extraOperatorLineCount = 1 as const 49 | 50 | const rewrittenLists: RewrittenListBlock[] = rewrittenListFileContent 51 | .split("\n\n") 52 | .map(lists => lists.split("\n")) 53 | .map(list => list[list.length - 1] === "" ? list.slice(0, -1) : list) 54 | // .slice(0, -1) 55 | .filter(list => list.length > extraOperatorLineCount) 56 | .map((list): RewrittenListBlock => ({ 57 | type: list[0] as RewrittenListBlock["type"], 58 | mapping: Object.fromEntries( 59 | list.slice(1).map(line => line.split(" ") as [string, string]) 60 | ) 61 | }) 62 | ) 63 | // .map(list => Object.fromEntries(list)) 64 | log("rewrittenLists", rewrittenLists) 65 | 66 | let prev : RewrittenListBlockAmend[] = [] 67 | let mergedReducedRewrittenLists: RewrittenListBlockRebase[] = [] 68 | 69 | let lastRebaseList: RewrittenListBlockRebase | null = null 70 | 71 | for (const list of rewrittenLists) { 72 | if (list.type === "amend") { 73 | prev.push(list) 74 | } else if (list.type === "rebase") { 75 | /** 76 | * merging time 77 | */ 78 | for (const amend of prev) { 79 | assert.equal(Object.keys(amend.mapping).length, 1) 80 | 81 | const [key, value] = Object.entries(amend.mapping)[0] 82 | 83 | /** 84 | * (try to) merge 85 | */ 86 | if (key in list.mapping) { 87 | if (value === list.mapping[key]) { 88 | // pointless 89 | continue 90 | } else { 91 | //throw new Error( 92 | // `NOT IMPLEMENTED - identical key in 'amend' and 'rebase', but different values.` 93 | //+ `(key = "${key}", amend's value = "${value}", rebase's value = "${list.mapping[key]}")` 94 | //) 95 | 96 | /** 97 | * amend 98 | * A->B 99 | * 100 | * rebase 101 | * A->C 102 | * 103 | * 104 | * hmm. 105 | * will we need to keep track of _when_ the post-rewrite happened as well? 106 | * (i.e. on what commit) 107 | * though, idk if that's possible, i think i already tried, 108 | * but since the post-rewrite script is called _after_ the amend/rebase happens, 109 | * it gives you the same commit that you already have, 110 | * i.e. the already rewritten one, instead of the previous one... 111 | * 112 | */ 113 | 114 | /** 115 | * for starters, we can try always favoring the amend over rebase 116 | */ 117 | Object.assign(list.mapping, amend.mapping) 118 | 119 | } 120 | } else { 121 | if (Object.values(list.mapping).includes(key)) { 122 | if (Object.values(list.mapping).includes(value)) { 123 | 124 | console.warn(`value already in values`, { 125 | [key]: value, 126 | [Object.entries(list.mapping).find(([_k, v]) => v === value)![0]]: value, 127 | }) 128 | // continue 129 | // throw; 130 | 131 | /** 132 | * happened when: 133 | * mark "edit" on commit A and B, 134 | * reach commit A, 135 | * do git commit --amend to change the title, 136 | * continue to commit B, 137 | * stop because of the another "edit", 138 | * reset to HEAD~ (commit A) (changes kept in workdir), 139 | * add all changes, 140 | * git commit --amend them into commit A. 141 | * 142 | * how things ended up in the rewritten-list, was that: 143 | * 144 | * amend 145 | * TMP_SHA -> NEW_SHA 146 | * 147 | * rebase 148 | * COMMIT_A_SHA -> TMP_SHA 149 | * COMMIT_B_SHA -> NEW_SHA 150 | * 151 | * 152 | * and would end up as 153 | * 154 | * COMMIT_A_SHA -> NEW_SHA 155 | * COMMIT_B_SHA -> NEW_SHA 156 | * 157 | * from our `git-rebase-todo` file, the ~~OLD_SHA_2~~ COMMIT_B_SHA was the original one found, 158 | * BUT, it pointed to commit B, not commit A! 159 | * 160 | * there were more mappings in the rewritten-list that included the commit A's SHA... 161 | * this is getting complicated. 162 | * 163 | * ---rm 164 | * the 1st mapping of TMP_SHA -> NEW_SHA ended up first in the rewritten-list inside an "amend". 165 | * the 2nd mapping of OLD_SHA_2 -> NEW_SHA ended up second in the rewritten-list inside the "rebase". 166 | * --- 167 | * 168 | * 169 | * TODO needs more testing. 170 | * 171 | * i mean, we very well could just get rid of the key->value pair 172 | * if there exists another one with the same value, 173 | * but how do we know which key to keep? 174 | * 175 | * wait... you keep the earliest key? 176 | * 177 | */ 178 | // fwiw, i don't think this algo makes you keep the earliest key (or does it?) 179 | Object.entries(list.mapping).forEach(([k, v]) => { 180 | if (v === value && k !== key) { 181 | // if it's not our key, delete it 182 | // (our key will get assigned a new value below.) 183 | console.info("deleting entry because duplicate A->B, C->A, D->B, ends up C->B, D->B, keeping only one", { 184 | [k]: list.mapping[k], 185 | }) 186 | delete list.mapping[k] 187 | } 188 | }) 189 | /** 190 | * TODO test if you "fixup" (reset, add, amend) first -- 191 | * does this reverse the order & you'd need the last key? 192 | * 193 | * TODO what if you did both and you need a key from the middle? lol 194 | * 195 | */ 196 | } 197 | 198 | /** 199 | * add the single new entry of amend's mapping into rebase's mapping. 200 | * it will get `reducePath`'d later. 201 | */ 202 | Object.assign(list.mapping, amend.mapping) 203 | } else { 204 | if (Object.values(list.mapping).includes(value)) { 205 | /** 206 | * TODO needs more testing. 207 | * especially which one is the actually newer one -- same questions apply as above. 208 | */ 209 | console.warn("the `rebase`'s mapping got a newer value than the amend, apparently. continuing.", { 210 | [key]: value, 211 | [Object.entries(list.mapping).find(([_k, v]) => v === value)![0]]: value, 212 | }) 213 | continue 214 | } else { 215 | console.warn( 216 | "NOT IMPLEMENTED - neither key nor value of 'amend' was included in the 'rebase'." 217 | + "\ncould be that we missed the ordering, or when we call 'reducePath', or something else.", 218 | { 219 | [key]: value, 220 | }) 221 | 222 | /** 223 | * i think this happens when commit gets rewritten, 224 | * then amended, and amended again. 225 | * 226 | * looks like it's fine to ignore it. 227 | */ 228 | continue 229 | } 230 | } 231 | } 232 | } 233 | 234 | prev = [] 235 | reducePath(list.mapping) 236 | mergedReducedRewrittenLists.push(list) 237 | lastRebaseList = list 238 | } else { 239 | throw new Error(`invalid list type (got "${(list as any).type}")`) 240 | } 241 | } 242 | 243 | if (prev.length) { 244 | /** 245 | * likely a rebase happenend first, 246 | * it was not `--apply`ied, 247 | * and then a `commit --amend` happend. 248 | * 249 | * if we don't handle this case, 250 | * the changes done in the `--amend` 251 | * would be lost. 252 | */ 253 | 254 | if (!lastRebaseList) { 255 | throw new Error(`NOT IMPLEMENTED - found "amend"(s) in rewritten-list, but did not find any "rebase"(s).`) 256 | } 257 | 258 | for (const amend of prev) { 259 | Object.assign(lastRebaseList.mapping, amend.mapping) 260 | reducePath(lastRebaseList.mapping) 261 | } 262 | 263 | prev = [] 264 | } 265 | 266 | if (!lastRebaseList) { 267 | throw new Error(`NOT IMPLEMENTED - did not find any "rebase"(s).`) 268 | } 269 | 270 | /** 271 | * TODO handle multiple rebases 272 | * or, multiple separate files for each new rebase, 273 | * since could potentially lose some info if skipping partial steps? 274 | */ 275 | 276 | log("mergedReducedRewrittenLists", mergedReducedRewrittenLists) 277 | 278 | const combinedRewrittenList = Object.entries(lastRebaseList.mapping).map(([k, v]) => k + " " + v).join("\n") + "\n" 279 | // fs.writeFileSync("rewritten-list", combinedRewrittenList) 280 | 281 | return { 282 | mergedReducedRewrittenLists, 283 | combinedRewrittenList, 284 | } 285 | } 286 | 287 | /** 288 | * mutates `obj` and returns it too 289 | */ 290 | export function reducePath(obj: StringFromToMap): StringFromToMap { 291 | let prevSize : number = -Infinity 292 | let entries : [string, string][] 293 | let keysMarkedForDeletion: Set = new Set() 294 | 295 | // as long as it continues to improve 296 | while (keysMarkedForDeletion.size > prevSize) { 297 | prevSize = keysMarkedForDeletion.size 298 | entries = Object.entries(obj) 299 | 300 | for (const [key, value] of entries) { 301 | const keyIsValue = key === value 302 | if (keyIsValue) { 303 | // would delete itself, thus skip 304 | continue 305 | } 306 | 307 | // const gotReducedAlready = !(key in obj) 308 | // if (gotReducedAlready) { 309 | // continue 310 | // } 311 | 312 | const valueIsAnotherKey = value in obj 313 | if (valueIsAnotherKey) { 314 | log("reducing. old:", key, "->", value, ";", value, "->", obj[value], "new:", key, "->", obj[value]) 315 | // reduce 316 | obj[key] = obj[value] 317 | keysMarkedForDeletion.add(value) 318 | } 319 | } 320 | } 321 | 322 | for (const key of keysMarkedForDeletion.keys()) { 323 | delete obj[key] 324 | } 325 | 326 | /** 327 | * we mutate the object, so NOT returning it makes it clear 328 | * that this function causes a side-effect (mutates the original object). 329 | * 330 | * but, in multiple cases when mapping, we forget to return the object, 331 | * so instead we'll do it here: 332 | */ 333 | return obj 334 | } 335 | 336 | if (!module.parent) { 337 | const prefix = "" // "test/.tmp-described.off/" 338 | const rewrittenListFile = fs.readFileSync(prefix + ".git/stacked-rebase/rewritten-list", { encoding: "utf-8" }) 339 | log({ rewrittenListFile }) 340 | 341 | const { mergedReducedRewrittenLists } = combineRewrittenLists(rewrittenListFile) 342 | 343 | const b4 = Object.keys(mergedReducedRewrittenLists[0].mapping) 344 | const after = Object.values(mergedReducedRewrittenLists[0].mapping) 345 | 346 | const path = require("path") 347 | const os = require("os") 348 | const dir = path.join(os.tmpdir(), "gsr-reduce-path") 349 | fs.mkdirSync(dir, { recursive: true }) 350 | 351 | const b4path = path.join(dir, "b4") 352 | const afterpath = path.join(dir, "after") 353 | fs.writeFileSync(b4path , b4 .join("\n") + "\n") 354 | fs.writeFileSync(afterpath, after.join("\n") + "\n") 355 | 356 | const N = after.length 357 | log({ N }) 358 | 359 | const currpath = path.join(dir, "curr") 360 | execSync(`git log --pretty=format:"%H" | head -n ${N} | tac - > ${currpath}`) 361 | execSync(`diff -us ${currpath} ${afterpath}`, { stdio: "inherit" }) 362 | } 363 | -------------------------------------------------------------------------------- /git-reconcile-rewritten-list/git-reconcile-rewritten-list.ts: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env node 2 | 3 | export * from "./postRewriteHook"; 4 | export * from "./combineRewrittenLists"; 5 | 6 | // eslint-disable-next-line @typescript-eslint/camelcase 7 | async function git_reconcile_rewritten_list_CLI(): Promise { 8 | /** 9 | * TODO 10 | */ 11 | 12 | process.stderr.write("\nCLI not implemented yet.\n\n"); 13 | process.exit(1); 14 | } 15 | 16 | if (!module.parent) { 17 | git_reconcile_rewritten_list_CLI(); 18 | } 19 | -------------------------------------------------------------------------------- /git-reconcile-rewritten-list/package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "git-reconcile-rewritten-list", 3 | "version": "0.0.0", 4 | "main": "dist/git-reconcile-rewritten-list.js", 5 | "types": "dist/git-reconcile-rewritten-list.d.ts", 6 | "author": "Kipras Melnikovas (https://kipras.org/)", 7 | "license": "UNLICENSED", 8 | "bin": { 9 | "git-reconcile-rewritten-list": "./dist/git-reconcile-rewritten-list.js" 10 | }, 11 | "scripts": { 12 | "build": "yarn tsc -b", 13 | "prepack": "yarn build" 14 | }, 15 | "devDependencies": { 16 | "typescript": "4.6.3" 17 | } 18 | } 19 | -------------------------------------------------------------------------------- /git-reconcile-rewritten-list/postRewriteHook.ts: -------------------------------------------------------------------------------- 1 | import fs from "fs"; 2 | import path from "path"; 3 | 4 | export type SetupPostRewriteHookForOpts = { 5 | /** 6 | * will be used to install the hook, 7 | * but will not be used inside the hook itself. 8 | */ 9 | dotGitDirPathForInstallingTheHook: string; 10 | 11 | rewrittenListOutputDirPathThatWillBeInsideDotGitDir?: string; 12 | }; 13 | 14 | export type SetupPostRewriteHookForRet = { 15 | pathToPostRewriteScript: string; 16 | }; 17 | 18 | export function setupPostRewriteHookFor( 19 | hookFileSuffix: string, // 20 | { 21 | dotGitDirPathForInstallingTheHook, 22 | rewrittenListOutputDirPathThatWillBeInsideDotGitDir = "reconcile-rewritten-list", 23 | }: SetupPostRewriteHookForOpts 24 | ): SetupPostRewriteHookForRet { 25 | setupHookWrapper("post-rewrite", dotGitDirPathForInstallingTheHook); 26 | 27 | const hooksDir: string = path.join(dotGitDirPathForInstallingTheHook, "hooks"); 28 | const pathToPostRewriteScript: string = path.join(hooksDir, "post-rewrite." + hookFileSuffix); 29 | 30 | /** 31 | * goal is to save the rewritten-list file, 32 | * which git deletes once the rebase is done, 33 | * 34 | * and when git-stacked-rebase gets called again 35 | * with `--apply` or whatever - to recover the commits. 36 | * 37 | */ 38 | 39 | const needle = "NEEDLE__GIT_RECONCILE_REWRITTEN_LIST"; 40 | const oldNeedles: string[] = [ 41 | "NEEDLE__GIT_STACKED_REBASE", // 42 | ]; 43 | 44 | const postRewriteScript: string = `\ 45 | #!/bin/sh 46 | 47 | # DO NOT EDIT THIS FILE MANUALLY 48 | # AUTO-GENERATED BY GIT-STACKED-REBASE 49 | 50 | # ${needle} 51 | 52 | DOT_GIT_DIR="$(git rev-parse --absolute-git-dir)" 53 | REGULAR_REWRITTEN_LIST_BACKUP_DIR_PATH="$DOT_GIT_DIR/${rewrittenListOutputDirPathThatWillBeInsideDotGitDir}" 54 | REWRITTEN_LIST_BACKUP_FILE_PATH="$REGULAR_REWRITTEN_LIST_BACKUP_DIR_PATH/rewritten-list" 55 | 56 | mkdir -p "$REGULAR_REWRITTEN_LIST_BACKUP_DIR_PATH" 57 | 58 | cat >> "$REWRITTEN_LIST_BACKUP_FILE_PATH" <." filename format, 95 | * e.g. `post-rewrite.git-stacked-rebase` 96 | * 97 | */ 98 | export function setupHookWrapper(hookName: string, dotGitDirPath: string): void { 99 | const needle = "NEEDLE__WRAPPER_HOOK"; 100 | const oldNeedles: string[] = [ 101 | "__ADDED_BY_GIT_STACKED_REBASE", // 102 | ]; 103 | const version = 1; 104 | 105 | const scriptContent = `\ 106 | #!/bin/sh 107 | 108 | # DO NOT EDIT THIS FILE MANUALLY 109 | # AUTO-GENERATED BY GIT-STACKED-REBASE 110 | 111 | # ${needle} ${version} 112 | 113 | DOT_GIT_DIR="$(git rev-parse --absolute-git-dir)" 114 | HOOKS_DIR="$DOT_GIT_DIR/hooks" 115 | SCRIPTS="$(find "$HOOKS_DIR" -name "${hookName}.*")" 116 | 117 | # if 0 scripts, 118 | # should not read stdin 119 | [ -z "$SCRIPTS" ] && { 120 | exit 0 121 | } 122 | 123 | STDIN="$(cat /dev/stdin)" 124 | 125 | for script in $SCRIPTS; do 126 | printf "$STDIN" | sh "$script" $* 127 | done`; 128 | 129 | const hooksDir: string = path.join(dotGitDirPath, "hooks"); 130 | const pathToHookScript: string = path.join(hooksDir, hookName); 131 | 132 | fs.mkdirSync(hooksDir, { recursive: true }); 133 | 134 | /** 135 | * prerequisite: 136 | * if a user has an existing custom post-rewrite script, 137 | * rename it to a different name, so that: 138 | * 1. we can use the `post-rewrite` filename for ourselves, 139 | * 2. the custom, now renamed, script, will still be called by us. 140 | */ 141 | renameCustomScriptIfExists(pathToHookScript, needle, oldNeedles); 142 | 143 | fs.writeFileSync(pathToHookScript, scriptContent, { mode: "777" }); 144 | } 145 | 146 | function renameCustomScriptIfExists(pathToHookScript: string, needle: string, oldNeedles: string[] = []): void { 147 | const isSafeToOverride = checkIfSafeToOverride({ filepath: pathToHookScript, needle, oldNeedles }); 148 | if (!isSafeToOverride) { 149 | const newPath = pathToHookScript + ".custom." + Math.random(); 150 | fs.renameSync(pathToHookScript, newPath); 151 | 152 | const filename = path.basename(newPath); 153 | const msg = `\ninfo: moved custom "post-rewrite" script into "${filename}", will still be called.\n\n`; 154 | process.stdout.write(msg); 155 | } 156 | } 157 | 158 | function checkIfSafeToOverride(opts: { 159 | filepath: string; // 160 | needle: string; 161 | oldNeedles?: string[]; 162 | }): boolean { 163 | const fileExists = fs.existsSync(opts.filepath); 164 | 165 | if (!fileExists) { 166 | return true; 167 | } 168 | 169 | const fileContent = fs.readFileSync(opts.filepath, { encoding: "utf-8" }); 170 | const fileExistsWithoutNeedles: boolean = 171 | !fileContent.includes(opts.needle) && 172 | !(opts.oldNeedles || []).some((oldNeedle) => fileContent.includes(oldNeedle)); 173 | 174 | return !fileExistsWithoutNeedles; 175 | } 176 | -------------------------------------------------------------------------------- /git-reconcile-rewritten-list/reducePath.spec.ts: -------------------------------------------------------------------------------- 1 | import assert from "assert"; 2 | 3 | import { reducePath } from "./combineRewrittenLists"; 4 | 5 | export default function testcase() { 6 | const obj1 = { 7 | a: "b", 8 | b: "c", 9 | c: "d", 10 | d: "e", 11 | 12 | g: "h", 13 | 14 | x: "x", 15 | 16 | y: "z", 17 | z: "z", 18 | 19 | /** 20 | * this might mean that we need to go backwards 21 | * rather than forwards 22 | * (multiple commits can be reported as rewritten into one, 23 | * but i don't think the opposite is possible) 24 | * 25 | * ~~and/or we might need another phase, 26 | * because currently, A -> F, 27 | * and both B and C stay at D.~~ 28 | * done 29 | * 30 | */ 31 | A: "D", 32 | B: "D", 33 | C: "D", 34 | D: "E", 35 | E: "F", 36 | }; 37 | 38 | reducePath(obj1); 39 | console.log(obj1); 40 | 41 | assert.deepStrictEqual(obj1, { 42 | a: "e", 43 | 44 | g: "h", 45 | 46 | x: "x", 47 | 48 | y: "z", 49 | 50 | A: "F", 51 | B: "F", 52 | C: "F", 53 | }); 54 | } 55 | -------------------------------------------------------------------------------- /git-reconcile-rewritten-list/tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "extends": "../tsconfig.json", 3 | "compilerOptions": { 4 | "outDir": "dist" // 5 | // "target": "es2015", 6 | // "module": "commonjs", 7 | }, 8 | "include": [ 9 | "**/*.ts" // 10 | ], 11 | "exclude": [ 12 | "node_modules", // 13 | "dist" 14 | ] 15 | } 16 | -------------------------------------------------------------------------------- /git-reconcile-rewritten-list/yarn.lock: -------------------------------------------------------------------------------- 1 | # THIS IS AN AUTOGENERATED FILE. DO NOT EDIT THIS FILE DIRECTLY. 2 | # yarn lockfile v1 3 | 4 | 5 | typescript@4.6.3: 6 | version "4.6.3" 7 | resolved "https://registry.yarnpkg.com/typescript/-/typescript-4.6.3.tgz#eefeafa6afdd31d725584c67a0eaba80f6fc6c6c" 8 | integrity sha512-yNIatDa5iaofVozS/uQJEl3JRWLKKGJKh6Yaiv0GLGSuhpFJe7P3SbHZ8/yjAHRQwKRoA6YZqlfjXWmVzoVSMw== 9 | -------------------------------------------------------------------------------- /goodnight.sh: -------------------------------------------------------------------------------- 1 | #!/bin/sh 2 | 3 | set -e 4 | 5 | # sync 'nightly' with the current branch in remote (that has been pushed) 6 | # 7 | # we don't want the local version of the current branch, 8 | # because if we haven't pushed new commits into remote yet 9 | # (thru current branch), we don't want to push thru nightly either. 10 | # 11 | CURR_BRANCH_PUSHED="origin/$(git branch --show)" 12 | CURR_BRANCH_PUSHED_COMMIT=$(git rev-parse "$CURR_BRANCH_PUSHED") 13 | git branch -f nightly "$CURR_BRANCH_PUSHED_COMMIT" 14 | 15 | LOCAL_NIGHTLY="$(git rev-parse nightly)" 16 | REMOTE_NIGHTLY="$(git rev-parse origin/nightly)" 17 | if [ "$LOCAL_NIGHTLY" = "$REMOTE_NIGHTLY" ]; then 18 | printf "Already up-to-date, skipping push.\n" 19 | else 20 | git push -f origin nightly 21 | fi 22 | 23 | POST_STACKED_REBASE_HOOK_PATH=".git/hooks/post-stacked-rebase" 24 | 25 | # BACKWARDS FIX FOR PREVIOUS VERSIONS: 26 | # remove the bad script: 27 | if grep "AUTO-GENERATED BY GIT-STACKED-REBASE's goodnight.sh script." "$POST_STACKED_REBASE_HOOK_PATH" >/dev/null 2>&1; then 28 | echo "bad post-rebase script found (previously generated by goodnight.sh), removing." 29 | rm "$POST_STACKED_REBASE_HOOK_PATH"; 30 | fi 31 | -------------------------------------------------------------------------------- /humanOp.ts: -------------------------------------------------------------------------------- 1 | /** 2 | * initially extracted as test utils, 3 | * but i feel like these could be used to automate things 4 | * thru the CLI that would need to be done inside the 5 | * interactive mode. 6 | */ 7 | 8 | import fs from "fs"; 9 | 10 | import { RegularRebaseCommand } from "./parse-todo-of-stacked-rebase/validator"; 11 | 12 | import { log } from "./util/log"; 13 | 14 | type CommonArgs = { 15 | filePath: string; // 16 | commitSHA: string; 17 | }; 18 | 19 | /** 20 | * TODO general "HumanOp" for `appendLineAfterNthCommit` & similar utils 21 | */ 22 | export function humanOpAppendLineAfterNthCommit(newLine: string, { filePath, commitSHA }: CommonArgs): void { 23 | const lines = readLines(filePath); 24 | const lineIdx: number = findLineByCommit(lines, commitSHA); 25 | 26 | log("commitSHA: %s, lineIdx: %s, newLine: %s", commitSHA, lineIdx, newLine); 27 | 28 | lines.splice(lineIdx, 0, newLine); 29 | 30 | writeLines(filePath, lines); 31 | } 32 | 33 | export function humanOpChangeCommandOfNthCommitInto( 34 | newCommand: RegularRebaseCommand, 35 | { commitSHA, filePath }: CommonArgs 36 | ): void { 37 | const lines = readLines(filePath); 38 | const lineIdx: number = findLineByCommit(lines, commitSHA); 39 | 40 | log("commitSHA: %s, lineIdx: %s, newCommand: %s", commitSHA, lineIdx, newCommand); 41 | 42 | const parts = lines[lineIdx].split(" "); 43 | parts[0] = newCommand; 44 | lines[lineIdx] = parts.join(" "); 45 | 46 | writeLines(filePath, lines); 47 | } 48 | 49 | export function humanOpRemoveLineOfCommit({ filePath, commitSHA }: CommonArgs): void { 50 | const lines: string[] = readLines(filePath); 51 | const idx: number = findLineByCommit(lines, commitSHA); 52 | 53 | /** 54 | * remove (implicit "drop") 55 | * 56 | * TODO respect some git config setting where implicit drops 57 | * are allowed or not (i think was info/warning/error) 58 | */ 59 | lines.splice(idx, 1); 60 | 61 | writeLines(filePath, lines); 62 | } 63 | 64 | export function modifyLines(filePath: string, modifier = (lines: string[]) => lines): void { 65 | const lines: string[] = readLines(filePath); 66 | const modifiedLines: string[] = modifier(lines); 67 | writeLines(filePath, modifiedLines); 68 | }; 69 | 70 | export function readLines(filePath: string): string[] { 71 | const file = fs.readFileSync(filePath, { encoding: "utf-8" }); 72 | const lines = file.split("\n"); 73 | return lines; 74 | } 75 | 76 | export function writeLines(filePath: string, lines: string[]): void { 77 | fs.writeFileSync(filePath, lines.join("\n")); 78 | } 79 | 80 | export function findLineByCommit(lines: string[], commitSHA: string): number { 81 | /** 82 | * TODO more advanced finding to allow for any command, 83 | * not just "picK" 84 | */ 85 | return lines.findIndex((line) => line.startsWith(`pick ${commitSHA}`)); 86 | } 87 | -------------------------------------------------------------------------------- /internal.ts: -------------------------------------------------------------------------------- 1 | /* eslint-disable @typescript-eslint/camelcase */ 2 | 3 | import Git from "nodegit"; 4 | 5 | import { AskQuestion } from "./util/createQuestion"; 6 | 7 | export const editor__internal = Symbol("editor__internal"); 8 | export const getGitConfig__internal = Symbol("getGitConfig__internal"); 9 | 10 | export const noEditor = { 11 | [editor__internal]: () => void 0, 12 | }; 13 | 14 | export const askQuestion__internal = Symbol("askQuestion__internal"); 15 | 16 | /** 17 | * meant to NOT be exported to the end user of the library 18 | */ 19 | export type InternalOnlyOptions = { 20 | [editor__internal]?: EitherEditor; 21 | [getGitConfig__internal]?: GetGitConfig; 22 | [askQuestion__internal]?: AskQuestion; 23 | }; 24 | 25 | export type EitherEditor = string | ((ctx: { filePath: string }) => void | Promise); 26 | 27 | export type GetGitConfig = (ctx: { 28 | GitConfig: typeof Git.Config; 29 | repo: Git.Repository; 30 | }) => Promise | Git.Config; 31 | -------------------------------------------------------------------------------- /native-git/branch.ts: -------------------------------------------------------------------------------- 1 | import { execSync } from "child_process"; 2 | 3 | import Git from "nodegit"; 4 | 5 | import { assertNever } from "../util/assertNever"; 6 | 7 | export type BranchFilter = "local" | "remote" | "all"; 8 | 9 | export const nativeGetBranchNames = (repoPath: string) => ( 10 | filter: BranchFilter, 11 | /* eslint-disable indent */ 12 | filterFlag = filter === "local" 13 | ? "--list" 14 | : filter === "remote" 15 | ? "--remotes" 16 | : filter === "all" 17 | ? "--all" 18 | : assertNever(filter), 19 | /* eslint-enable indent */ 20 | cmd = `git branch ${filterFlag} --format "%(refname:short)"`, 21 | ret = execSync(cmd, { 22 | cwd: repoPath, // 23 | encoding: "utf-8", 24 | }).split("\n") 25 | ): string[] => ret; 26 | 27 | export const getBranches = ( 28 | repo: Git.Repository // 29 | ) => async ( 30 | filter: BranchFilter, // 31 | branchNames: string[] = nativeGetBranchNames(repo.workdir())(filter), 32 | lookupPromises = branchNames.map((b) => Git.Branch.lookup(repo, b, Git.Branch.BRANCH.ALL)), 33 | ret = Promise.all(lookupPromises) 34 | ): Promise => ret; 35 | 36 | export const nativePush = ( 37 | repoPath: string // 38 | ) => ( 39 | branchNames: string[] = nativeGetBranchNames(repoPath)("local"), // 40 | { remote = "origin", setupRemoteTracking = true } = {}, 41 | remoteTrackingFlag = setupRemoteTracking ? "-u" : "" 42 | ): string[] => ( 43 | branchNames.forEach((branch) => { 44 | const cmd = `git push ${remoteTrackingFlag} ${remote} ${branch}`; 45 | execSync(cmd, { 46 | cwd: repoPath, 47 | encoding: "utf-8", 48 | }); 49 | }), 50 | branchNames 51 | ); 52 | -------------------------------------------------------------------------------- /native-git/config.ts: -------------------------------------------------------------------------------- 1 | import { exec } from "child_process"; 2 | 3 | export async function nativeConfigGet(key: string): Promise { 4 | return new Promise((res, rej) => 5 | exec(`git config --get ${key}`, { encoding: "utf-8" }, (err, stdout) => { 6 | return err ? rej(err) : res(stdout.trim()); 7 | }) 8 | ); 9 | } 10 | -------------------------------------------------------------------------------- /native-git/libgit-apis-in-use.js: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env node 2 | 3 | /** 4 | * 5 | * how hard would be to get rid of libgit? 6 | * 7 | * 1. libgit2 project itself is kinda dead... 8 | * - v much outdated from core git 9 | * - many issues not fixed 10 | * - updates not coming, even to the previously pretty up-to-date nodegit pkg 11 | * - issues not being closed or even addressed, just dead vibes all around.. 12 | * 13 | * 2. building is a chore, takes up almost 90MB, 14 | * breaks between node versions & needs re-build for each... 15 | */ 16 | 17 | fs = require("fs") 18 | cp = require("child_process") 19 | _ = require("lodash") 20 | 21 | SHORT = !!process.env.SHORT 22 | 23 | fnCalls = cp.execSync(`rg "Git\\.\\w+\\.\\w+" . -o --no-line-number --no-filename`) 24 | typeUsages = cp.execSync(`rg ": Git\\.\\w+" . -o --no-line-number --no-filename`) 25 | fnRetTypes = cp.execSync(`rg "=> Git\\.\\w+" . -o --no-line-number --no-filename`) 26 | 27 | prep = x => x.toString().split('\n').filter(x => !!x) 28 | sort = (A, B) => A.group.localeCompare(B.group) 29 | 30 | fnCallsSorted = prep(fnCalls) 31 | .map(x => ({ 32 | full: x, 33 | group: x.split(".").slice(1,2).join("."), 34 | api: x.split(".").slice(2).join("."), 35 | kind: "fn_call", 36 | })) 37 | .sort(sort) 38 | 39 | typeUsagesSorted = prep(typeUsages) 40 | .map(x => x.slice(2)) 41 | .map(x => ({ 42 | full: x, 43 | group: x.split(".").slice(1,2).join("."), 44 | kind: "type_usage", 45 | })) 46 | .sort(sort) 47 | 48 | fnRetTypesSorted = prep(fnRetTypes) 49 | .map(x => x.slice(3)) 50 | .map(x => ({ 51 | full: x, 52 | group: x.split(".").slice(1,2).join("."), 53 | kind: "fn_ret_type", 54 | })) 55 | .sort(sort) 56 | 57 | // 58 | 59 | Array.prototype.collect = function collect(cb) { 60 | return cb(this) 61 | } 62 | 63 | mergedGroupedObjs = fnCallsSorted.concat(typeUsagesSorted).concat(fnRetTypesSorted) 64 | .sort(sort) 65 | .collect(merged => Object.entries(_.groupBy(merged, "group"))) 66 | .collect(mergedGrouped => mergedGrouped.map(([group, items]) => ({ 67 | cnt: items.length, 68 | group, 69 | // cnt_fn_call: items.filter(x => x.kind === "fn_call").length, 70 | // cnt_type_usage: items.filter(x => x.kind === "type_usage").length, 71 | // cnt_fn_ret_type: items.filter(x => x.kind === "fn_ret_type").length, 72 | // cnts: [items.filter(x => x.kind === "fn_call").length, 73 | // items.filter(x => x.kind === "type_usage").length, 74 | // items.filter(x => x.kind === "fn_ret_type").length], 75 | items, 76 | })) 77 | .sort((A, B) => B.cnt - A.cnt)) 78 | 79 | sum = (acc, curr) => acc + curr 80 | totalCount = mergedGroupedObjs.map(x => x.cnt).reduce(sum, 0) 81 | 82 | // detailed: 83 | for (const group of mergedGroupedObjs) { 84 | process.stdout.write(group.cnt + " " + group.group + "\n") 85 | 86 | const groupedItems = Object.entries(_.groupBy(group.items, "kind")) 87 | 88 | for (let [kind, items] of groupedItems) { 89 | let extra = "" 90 | if (kind === "fn_call") { 91 | extra += Object.entries(_.groupBy(items, "api")) 92 | .sort((A, B) => B[1].length - A[1].length) 93 | .map(([api, apiItems]) => `\n${" ".repeat(8)}${apiItems.length} ${api}`) 94 | .join("") 95 | 96 | kind = SHORT ? "()" : (kind + "()") 97 | } else if (kind === "type_usage") { 98 | kind = SHORT ? ":" : (":" + kind) 99 | } else if (kind === "fn_ret_type") { 100 | kind = SHORT ? "=>" : ("=>" + kind) 101 | } 102 | 103 | process.stdout.write(" ".repeat(4) + items.length + " " + kind + extra + "\n") 104 | } 105 | 106 | process.stdout.write("\n") 107 | } 108 | 109 | // basic, quick overview 110 | console.log({mergedGroupedObjs}) 111 | 112 | // full 113 | // console.log("mergedGroupedObjs", JSON.stringify(mergedGroupedObjs, null, 2)) 114 | 115 | console.log({totalCount}) 116 | -------------------------------------------------------------------------------- /nightly-setup-and-update.sh: -------------------------------------------------------------------------------- 1 | #!/bin/sh 2 | 3 | # meant for users of GSR; 4 | # for updating nightly - use ./goodnight.sh 5 | 6 | git checkout nightly 7 | git pull --rebase 8 | 9 | yarn install:all 10 | yarn build 11 | 12 | ## https://stackoverflow.com/a/69259147/9285308 13 | #yarn global add link:. 14 | 15 | # https://github.com/yarnpkg/yarn/issues/3256#issuecomment-433096967 16 | yarn global add file:$PWD 17 | -------------------------------------------------------------------------------- /nvim-git-rebase-todo/README: -------------------------------------------------------------------------------- 1 | showing the current commit's info (while in the git-rebase-todo file) would save a lot of time. 2 | 3 | could potentially add extra stuff like grouping files by which commits modified them, or vice versa, etc. 4 | 5 | need a monorepo setup for this, if nvim's node-client [1] works well 6 | 7 | [1] https://github.com/neovim/node-client 8 | 9 | 10 | setup 11 | ========== 12 | 13 | yarn 14 | 15 | 16 | development 17 | =========== 18 | 19 | # terminal 1 20 | yarn dev 21 | 22 | # terminal 2 23 | NVIM_LISTEN_ADDRESS=/tmp/nvim NVIM_NODE_LOG_FILE=nvim.log nvim ../.git/stacked-rebase/git-rebase-todo 24 | 25 | # terminal 3 (potentially vsplit w/ terminal 2) 26 | NVIM_LISTEN_ADDRESS=/tmp/nvim node 27 | 28 | let v, w, c 29 | nvim = await require('neovim/scripts/nvim') // comes from https://github.com/neovim/node-client/blob/e01ecaa6ba616738e6fc2b9d1b283f095a84899b/packages/neovim/scripts/nvim.js 30 | v = nvim 31 | w = await v.getWindow() 32 | c = await w.client 33 | // v.command("vsp") 34 | // v.command("q") 35 | await c.line 36 | 37 | line = await c.line 38 | commit = line.split(" ")[1] 39 | const cp = require("child_process") 40 | stat = cp.execSync(`git show --stat ${commit}`, { encoding: "utf8" }).split("\n") 41 | 42 | # potentially terminal 4 43 | watch cat nvim.log 44 | 45 | # upon changes in source code, re-run in nvim 46 | # to update the generated rplugin.vim manifest 47 | :UpdateRemotePlugins 48 | 49 | --- 50 | 51 | see also: 52 | - https://github.com/neovim/node-client 53 | - https://neovim.io/node-client/ 54 | - https://neovim.io/node-client/modules.html 55 | - https://neovim.io/node-client/classes/Neovim.html 56 | - https://neovim.io/node-client/classes/NeovimClient.html 57 | - https://neovim.io/node-client/classes/Window.html 58 | - ! https://neovim.io/doc/user/api.html 59 | - https://neovim.io/doc/user/windows.html#window 60 | - same as :help 61 | - :help events 62 | - :help nvim_open_win|nvim_win_close|nvim_win_get_cursor 63 | - :help BufEnter|BufLeave|CursorMoved|CursorMovedI 64 | - etc 65 | - ! https://github.com/neovim/node-client/tree/master/packages/neovim/src/api/Neovim.ts 66 | - Neovim 67 | - Buffer 68 | - Window 69 | - NerdTree's `ToggleTabTree` & following to what it leads (via simple file search) 70 | - https://github.com/preservim/nerdtree/blob/eed488b1cd1867bd25f19f90e10440c5cc7d6424/autoload/nerdtree/ui_glue.vim#L643 71 | 72 | mixing these up was the most useful. 73 | 74 | in general, i tried to read from the start of the API reference, but just couldn't focus - nothing seemed important. 75 | 76 | then, found the nodejs REPL demo from node-client: 77 | - https://github.com/neovim/node-client/blob/e01ecaa6ba616738e6fc2b9d1b283f095a84899b/packages/neovim/scripts/nvim.js 78 | and started playing w/ stuff. 79 | 80 | it took a long time to figure out how to properly link the (remote) plugin for nvim to detect. 81 | the package.json's "postinstall" script now has this. 82 | though, TODO: create a PR to upstream to clear this up. i wasted a good few hours on this. 83 | 84 | & then bit by bit, continued trying to figure out how to write the plugin. 85 | i think after creating a new Buffer and then opening a new Window with it, 86 | i knew what's up, and switched from the REPL to the TS file. 87 | 88 | there were many, many iterations of it. sadly didn't commit any of them, 89 | only the latest one now.. good fun anyhow! 90 | 91 | really appreciate both the RPC capabilities, and the nvim-node-client. 92 | wouldn't have been able to do this in lua -- not in a single day for sure. 93 | -------------------------------------------------------------------------------- /nvim-git-rebase-todo/nvim-git-rebase-todo.ts: -------------------------------------------------------------------------------- 1 | /* eslint-disable indent */ 2 | /* eslint-disable no-cond-assign */ 3 | 4 | import cp from "child_process"; 5 | import util from "util"; 6 | 7 | import { NvimPlugin, Buffer, Window } from "neovim"; 8 | import { OpenWindowOptions } from "neovim/lib/api/Neovim"; 9 | import { AutocmdOptions } from "neovim/lib/host/NvimPlugin"; 10 | 11 | const execAsync = util.promisify(cp.exec); 12 | 13 | /** 14 | * TODO `console.log`s break stuff 15 | */ 16 | 17 | export default function nvimGitRebaseTodo(plugin: NvimPlugin): void { 18 | /** 19 | * TODO make actually configurable 20 | */ 21 | const config = { 22 | colorful: false, 23 | minHeight: 3, 24 | maxWidth: 60, 25 | fixedWidth: 60, 26 | showStatParsingCount: false, 27 | showCacheHitOrMiss: false, 28 | 29 | // relativeToCursor: true, 30 | // closeToLeft: 5, 31 | // rowLowerIfCloseToLeft: 1, 32 | relativeToCursor: false, 33 | closeToLeft: 0, 34 | rowLowerIfCloseToLeft: 0, 35 | }; 36 | 37 | /** 38 | * 39 | */ 40 | 41 | const { nvim: vim } = plugin; 42 | 43 | plugin.setOptions({ dev: false }); 44 | 45 | const pattern = "git-rebase-todo" as const; 46 | const commonOptions: AutocmdOptions = { 47 | sync: false, // 48 | pattern, 49 | eval: 'expand("")', // i don't know what this does 50 | }; 51 | 52 | /** 53 | * :help events 54 | */ 55 | plugin.registerAutocmd( 56 | "BufEnter", // 57 | () => drawLinesOfCommittishStat(), 58 | { 59 | ...commonOptions, 60 | } 61 | ); 62 | 63 | plugin.registerAutocmd( 64 | "BufLeave", // 65 | () => hideWindow(), 66 | { 67 | ...commonOptions, 68 | } 69 | ); 70 | 71 | plugin.registerAutocmd( 72 | "CursorMoved", // 73 | () => drawLinesOfCommittishStat(), 74 | { 75 | ...commonOptions, 76 | } 77 | ); 78 | 79 | /** 80 | * only needed when you create a new line, 81 | * otherwise could get rid... 82 | * 83 | * TODO OPTIMIZE 84 | */ 85 | plugin.registerAutocmd( 86 | "CursorMovedI", // 87 | () => drawLinesOfCommittishStat(), 88 | { 89 | ...commonOptions, 90 | } 91 | ); 92 | 93 | /** 94 | * 95 | */ 96 | 97 | let gBuffer: Buffer; 98 | let gWindow: Window; 99 | 100 | const initBuffer = async (): Promise => { 101 | const listed = false; 102 | const scratch = true; 103 | const buffer: number | Buffer = await vim.createBuffer(listed, scratch); 104 | 105 | if (typeof buffer === "number") { 106 | throw new Error("failed to create buffer"); 107 | } 108 | 109 | gBuffer = buffer; 110 | return buffer; 111 | }; 112 | 113 | const updateBuffer = async (stat: string[]): Promise => { 114 | if (!gBuffer) { 115 | await initBuffer(); 116 | } 117 | 118 | await gBuffer.setLines(stat, { 119 | start: 0, 120 | /** 121 | * TODO why is the below broken & below below works? 122 | */ 123 | // end: Math.max(stat.length, (await gBuffer.lines).length), // needed to remove old lines 124 | end: (await gBuffer.lines).length, // needed to remove old lines 125 | }); 126 | 127 | return gBuffer; 128 | }; 129 | 130 | const hideWindow = async (): Promise => { 131 | /** 132 | * overkill 133 | */ 134 | // if (!gWindow) { 135 | // return; 136 | // } 137 | // const force = true; 138 | // await gWindow.close(force); 139 | // gWindow = null as any; // TODO 140 | 141 | /** 142 | * does NOT work 143 | */ 144 | // await vim.windowConfig(gWindow, { 145 | // width: 0, 146 | // height: 0, 147 | // }); 148 | 149 | /** 150 | * works 151 | */ 152 | gWindow.width = 0; 153 | gWindow.height = 0; 154 | }; 155 | 156 | type WH = { 157 | width: number; 158 | height: number; 159 | }; 160 | 161 | type SetWindowRelativeToCursorOpts = WH; 162 | const getRelativeWindowOptions = async ({ 163 | width, // 164 | height, 165 | }: SetWindowRelativeToCursorOpts): Promise => { 166 | const [relWin, cursor, col] = await Promise.all([ 167 | vim.window, 168 | vim.window.cursor, 169 | config.closeToLeft || vim.window.width, 170 | ]); 171 | 172 | return { 173 | // relative: "cursor", 174 | relative: "win" as const, 175 | win: (relWin as unknown) as number, // TODO TS fix incorrect types @ upstream 176 | // 177 | bufpos: cursor, 178 | // 179 | row: -1 + (config.closeToLeft ? config.rowLowerIfCloseToLeft : 0), // TODO investigate when in very last row & fully scrolled down with Ctrl-E 180 | col, 181 | // 182 | width, 183 | height, 184 | // 185 | style: "minimal", 186 | }; 187 | }; 188 | 189 | type InitWindowOpts = WH & { 190 | buffer: Buffer; 191 | }; 192 | 193 | const initWindow = async ({ buffer, width, height }: InitWindowOpts): Promise => { 194 | /** 195 | * TODO update the buffer here w/ `stat` (`lines`) 196 | * instead of taking it in as param 197 | */ 198 | 199 | let relWin: Window; 200 | let col: number; 201 | let opts: OpenWindowOptions; 202 | 203 | if (config.relativeToCursor) { 204 | [relWin, col, opts] = await Promise.all([ 205 | vim.window, // 206 | vim.window.width, 207 | getRelativeWindowOptions({ width, height }), 208 | ]); 209 | } else { 210 | [relWin, col] = await Promise.all([ 211 | vim.window, // 212 | vim.window.width, 213 | ]); 214 | opts = { 215 | relative: "win", 216 | win: relWin.id, 217 | // 218 | width, 219 | height, 220 | // 221 | // anchor: "NE", // TODO is this needed? 222 | row: 0 + (config.closeToLeft ? config.rowLowerIfCloseToLeft : 0), 223 | col, 224 | // 225 | style: "minimal", 226 | }; 227 | } 228 | 229 | const enter = false; 230 | const window: number | Window = await vim.openWindow(buffer, enter, opts); 231 | 232 | if (typeof window === "number") { 233 | throw new Error("failed to create window"); 234 | } 235 | 236 | gWindow = window; 237 | }; 238 | 239 | const updateWindow = async ({ buffer, width, height }: InitWindowOpts): Promise => { 240 | if (!gWindow) { 241 | await initWindow({ 242 | buffer, 243 | width, 244 | height, // 245 | }); 246 | } 247 | 248 | /** 249 | * TODO REFACTOR 250 | * the `hideWindow` should be a prop probably, 251 | * instead of implying it like this w/ `0x0` 252 | */ 253 | if (width === 0 && height === 0) { 254 | await hideWindow(); 255 | } else { 256 | if (config.relativeToCursor) { 257 | const opts = await getRelativeWindowOptions({ 258 | width, // 259 | height, 260 | }); 261 | await vim.windowConfig(gWindow, opts); 262 | } else { 263 | gWindow.width = width; 264 | gWindow.height = height; 265 | } 266 | } 267 | }; 268 | 269 | /** 270 | * 271 | */ 272 | 273 | const getCommittishOfCurrentLine = async (): Promise => { 274 | let line: string | null; 275 | try { 276 | line = await vim.line; 277 | } catch { 278 | return null; 279 | } 280 | 281 | if (!line || typeof line !== "string") { 282 | return null; 283 | } 284 | 285 | const split = line.split(" "); 286 | const wantedIdx: number = 1; 287 | 288 | if (split.length < wantedIdx + 1) { 289 | return null; 290 | } 291 | 292 | const committish: string | undefined = split[wantedIdx]; 293 | 294 | if (typeof committish !== "string") { 295 | return null; 296 | } 297 | 298 | return committish; 299 | }; 300 | 301 | let gParseStatCount: number = 0; 302 | /** 303 | * TODO consider an option where we precompute stats for all commits, 304 | * set the window to the the longest height out of them, 305 | * and keep the changed files aligned across committish lines, 306 | * so that it's even more obvious what changed vs what not. 307 | * 308 | * could even show all filenames together, 309 | * but the non-changed ones with less contrast 310 | * 311 | */ 312 | const getStatLines = async (committish: NonNullable): Promise => { 313 | /** 314 | * remove everything except the `/path/to/file | N +-` 315 | */ 316 | const gitShowCmd: string = [ 317 | "git", 318 | "show", 319 | committish, 320 | `--stat=${config.maxWidth}`, // [[1]] 321 | "--pretty=format:''", 322 | config.colorful ? "--color=always" : "", // TODO proper impl with vim 323 | "| head -n -1" /** remove last item (X files changed, Y insertions, Z deletions) */, 324 | ].join(" "); 325 | 326 | const stat: string[] = await execAsync(gitShowCmd, { encoding: "utf-8" }).then((x) => 327 | /** 328 | * if stderr, it's an error. 329 | * TODO ideally would be more explicit, 330 | * i.e. "hideWindow" or smthn 331 | */ 332 | x.stderr // 333 | ? [] 334 | : x.stdout.split("\n") 335 | ); 336 | 337 | gParseStatCount++; 338 | 339 | if (!stat.length) { 340 | return []; 341 | } 342 | 343 | return stat; 344 | }; 345 | 346 | type Committish = string | null; 347 | type State = { 348 | committish: Committish; 349 | 350 | /** 351 | * will not include the extra "informational" lines; 352 | * use `getExtraInfoLines` for that. 353 | */ 354 | lines: string[]; 355 | 356 | width: number; 357 | height: number; 358 | }; 359 | type CommitStateOpts = { 360 | isCacheHit: boolean; // 361 | }; 362 | 363 | let prevState: State | null = null; 364 | const committishToStatCache: Map, State> = new Map(); 365 | const commitState = ( 366 | state: State, 367 | { 368 | isCacheHit = false, // 369 | }: Partial = {} 370 | ): Promise => 371 | Promise.resolve() 372 | .then((): string[] => [ 373 | ...getExtraInfoLines({ isCacheHit }), 374 | ...state.lines, // do not mutate state.lines 375 | ]) 376 | .then((lines) => updateBuffer(lines)) 377 | .then((buffer) => 378 | updateWindow({ 379 | buffer, // 380 | width: state.width, 381 | height: state.height, 382 | }) 383 | ) 384 | .then(() => state.committish && committishToStatCache.set(state.committish, state)) 385 | .then(() => (prevState = state)); 386 | 387 | function getExtraInfoLines(opts: CommitStateOpts): string[] { 388 | const extra: string[] = []; 389 | 390 | if (config.showStatParsingCount) { 391 | extra.push(gParseStatCount.toString()); 392 | } 393 | if (config.showCacheHitOrMiss && opts.isCacheHit) { 394 | extra.push("cache hit"); 395 | } 396 | 397 | return extra; 398 | } 399 | 400 | const drawLinesOfCommittishStat = async (): Promise => { 401 | const committish: Committish = await getCommittishOfCurrentLine(); 402 | 403 | if (committish === prevState?.committish) { 404 | return prevState; 405 | } 406 | 407 | if (!committish) { 408 | return commitState({ 409 | committish, 410 | lines: [], 411 | width: 0, 412 | height: 0, 413 | }); 414 | } 415 | 416 | let tmp: State | undefined; 417 | if ((tmp = committishToStatCache.get(committish))) { 418 | return commitState(tmp, { 419 | isCacheHit: true, // 420 | }); 421 | } 422 | 423 | const stat: string[] = await getStatLines(committish); 424 | 425 | if (!stat.length) { 426 | return commitState({ 427 | committish, 428 | lines: [], 429 | width: 0, 430 | height: 0, 431 | }); 432 | } 433 | 434 | const longestLineLength: number = stat.reduce((max, curr) => Math.max(max, curr.length), 0); 435 | 436 | if (longestLineLength === 0) { 437 | return commitState({ 438 | committish, 439 | lines: [], 440 | width: 0, 441 | height: 0, 442 | }); 443 | } 444 | 445 | /** 446 | * config.maxWidth shouldn't be needed here, 447 | * since it's being limited in [[1]] 448 | */ 449 | const width: number = config.fixedWidth ?? longestLineLength; 450 | 451 | /** 452 | * TODO could parse whole git-rebase-todo file, find the max height, 453 | * and use it, so that we never encounter any jumping. 454 | * 455 | * could even allow configuring this behavior. 456 | */ 457 | // const height: number = Math.max(stat.length, config.minHeight); 458 | const height: number = Math.max(stat.length, config.minHeight + Number(!!config.showStatParsingCount)); 459 | 460 | return commitState({ 461 | committish, 462 | lines: stat, 463 | width, 464 | height, 465 | }); 466 | }; 467 | } 468 | -------------------------------------------------------------------------------- /nvim-git-rebase-todo/package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "nvim-git-rebase-todo", 3 | "version": "0.0.0", 4 | "main": "dist/nvim-git-rebase-todo.js", 5 | "types": "dist/nvim-git-rebase-todo.d.ts", 6 | "author": "Kipras Melnikovas (https://kipras.org/)", 7 | "license": "UNLICENSED", 8 | "scripts": { 9 | "dev": "tsc -b -w", 10 | "build": "tsc -b", 11 | "postinstall": "DIR=\"${NVIM_RPLUGIN_MANIFEST:-$HOME/.config/nvim/rplugin/node}\"; mkdir -p \"$DIR\" && ln -s -f \"$(pwd)\" \"/$DIR\"" 12 | }, 13 | "dependencies": { 14 | "neovim": "4.10.1" 15 | }, 16 | "devDependencies": { 17 | "typescript": "4.6.3" 18 | } 19 | } 20 | -------------------------------------------------------------------------------- /nvim-git-rebase-todo/tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "extends": "../tsconfig.json", 3 | "compilerOptions": { 4 | "outDir": "dist", 5 | "target": "es2015", 6 | "module": "commonjs", 7 | }, 8 | "include": [ 9 | "**/*.ts", // 10 | ], 11 | "exclude": [ 12 | "node_modules", // 13 | "dist" 14 | ] 15 | } 16 | -------------------------------------------------------------------------------- /nvim-git-rebase-todo/yarn.lock: -------------------------------------------------------------------------------- 1 | # THIS IS AN AUTOGENERATED FILE. DO NOT EDIT THIS FILE DIRECTLY. 2 | # yarn lockfile v1 3 | 4 | 5 | "@colors/colors@1.5.0": 6 | version "1.5.0" 7 | resolved "https://registry.yarnpkg.com/@colors/colors/-/colors-1.5.0.tgz#bb504579c1cae923e6576a4f5da43d25f97bdbd9" 8 | integrity sha512-ooWCrlZP11i8GImSjTHYHLkvFDP48nS4+204nGb1RiX/WXYHmJA2III9/e2DWVabCESdW7hBAEzHRqUn9OUVvQ== 9 | 10 | "@dabh/diagnostics@^2.0.2": 11 | version "2.0.3" 12 | resolved "https://registry.yarnpkg.com/@dabh/diagnostics/-/diagnostics-2.0.3.tgz#7f7e97ee9a725dffc7808d93668cc984e1dc477a" 13 | integrity sha512-hrlQOIi7hAfzsMqlGSFyVucrx38O+j6wiGOf//H2ecvIEqYN4ADBSS2iLMh5UFyDunCNniUIPk/q3riFv45xRA== 14 | dependencies: 15 | colorspace "1.1.x" 16 | enabled "2.0.x" 17 | kuler "^2.0.0" 18 | 19 | "@msgpack/msgpack@^2.7.1": 20 | version "2.7.2" 21 | resolved "https://registry.yarnpkg.com/@msgpack/msgpack/-/msgpack-2.7.2.tgz#f34b8aa0c49f0dd55eb7eba577081299cbf3f90b" 22 | integrity sha512-rYEi46+gIzufyYUAoHDnRzkWGxajpD9vVXFQ3g1vbjrBm6P7MBmm+s/fqPa46sxa+8FOUdEuRQKaugo5a4JWpw== 23 | 24 | async@^3.1.0: 25 | version "3.2.3" 26 | resolved "https://registry.yarnpkg.com/async/-/async-3.2.3.tgz#ac53dafd3f4720ee9e8a160628f18ea91df196c9" 27 | integrity sha512-spZRyzKL5l5BZQrr/6m/SqFdBN0q3OCI0f9rjfBzCMBIP4p75P620rR3gTmaksNOhmzgdxcaxdNfMy6anrbM0g== 28 | 29 | color-convert@^1.9.3: 30 | version "1.9.3" 31 | resolved "https://registry.yarnpkg.com/color-convert/-/color-convert-1.9.3.tgz#bb71850690e1f136567de629d2d5471deda4c1e8" 32 | integrity sha512-QfAUtd+vFdAtFQcC8CCyYt1fYWxSqAiK2cSD6zDB8N3cpsEBAvRxp9zOGg6G/SHHJYAT88/az/IuDGALsNVbGg== 33 | dependencies: 34 | color-name "1.1.3" 35 | 36 | color-name@1.1.3: 37 | version "1.1.3" 38 | resolved "https://registry.yarnpkg.com/color-name/-/color-name-1.1.3.tgz#a7d0558bd89c42f795dd42328f740831ca53bc25" 39 | integrity sha1-p9BVi9icQveV3UIyj3QIMcpTvCU= 40 | 41 | color-name@^1.0.0: 42 | version "1.1.4" 43 | resolved "https://registry.yarnpkg.com/color-name/-/color-name-1.1.4.tgz#c2a09a87acbde69543de6f63fa3995c826c536a2" 44 | integrity sha512-dOy+3AuW3a2wNbZHIuMZpTcgjGuLU/uBL/ubcZF9OXbDo8ff4O8yVp5Bf0efS8uEoYo5q4Fx7dY9OgQGXgAsQA== 45 | 46 | color-string@^1.6.0: 47 | version "1.9.0" 48 | resolved "https://registry.yarnpkg.com/color-string/-/color-string-1.9.0.tgz#63b6ebd1bec11999d1df3a79a7569451ac2be8aa" 49 | integrity sha512-9Mrz2AQLefkH1UvASKj6v6hj/7eWgjnT/cVsR8CumieLoT+g900exWeNogqtweI8dxloXN9BDQTYro1oWu/5CQ== 50 | dependencies: 51 | color-name "^1.0.0" 52 | simple-swizzle "^0.2.2" 53 | 54 | color@^3.1.3: 55 | version "3.2.1" 56 | resolved "https://registry.yarnpkg.com/color/-/color-3.2.1.tgz#3544dc198caf4490c3ecc9a790b54fe9ff45e164" 57 | integrity sha512-aBl7dZI9ENN6fUGC7mWpMTPNHmWUSNan9tuWN6ahh5ZLNk9baLJOnSMlrQkHcrfFgz2/RigjUVAjdx36VcemKA== 58 | dependencies: 59 | color-convert "^1.9.3" 60 | color-string "^1.6.0" 61 | 62 | colorspace@1.1.x: 63 | version "1.1.4" 64 | resolved "https://registry.yarnpkg.com/colorspace/-/colorspace-1.1.4.tgz#8d442d1186152f60453bf8070cd66eb364e59243" 65 | integrity sha512-BgvKJiuVu1igBUF2kEjRCZXol6wiiGbY5ipL/oVPwm0BL9sIpMIzM8IK7vwuxIIzOXMV3Ey5w+vxhm0rR/TN8w== 66 | dependencies: 67 | color "^3.1.3" 68 | text-hex "1.0.x" 69 | 70 | enabled@2.0.x: 71 | version "2.0.0" 72 | resolved "https://registry.yarnpkg.com/enabled/-/enabled-2.0.0.tgz#f9dd92ec2d6f4bbc0d5d1e64e21d61cd4665e7c2" 73 | integrity sha512-AKrN98kuwOzMIdAizXGI86UFBoo26CL21UM763y1h/GMSJ4/OHU9k2YlsmBpyScFo/wbLzWQJBMCW4+IO3/+OQ== 74 | 75 | fecha@^4.2.0: 76 | version "4.2.1" 77 | resolved "https://registry.yarnpkg.com/fecha/-/fecha-4.2.1.tgz#0a83ad8f86ef62a091e22bb5a039cd03d23eecce" 78 | integrity sha512-MMMQ0ludy/nBs1/o0zVOiKTpG7qMbonKUzjJgQFEuvq6INZ1OraKPRAWkBq5vlKLOUMpmNYG1JoN3oDPUQ9m3Q== 79 | 80 | fn.name@1.x.x: 81 | version "1.1.0" 82 | resolved "https://registry.yarnpkg.com/fn.name/-/fn.name-1.1.0.tgz#26cad8017967aea8731bc42961d04a3d5988accc" 83 | integrity sha512-GRnmB5gPyJpAhTQdSZTSp9uaPSvl09KoYcMQtsB9rQoOmzs9dH6ffeccH+Z+cv6P68Hu5bC6JjRh4Ah/mHSNRw== 84 | 85 | inherits@^2.0.3: 86 | version "2.0.4" 87 | resolved "https://registry.yarnpkg.com/inherits/-/inherits-2.0.4.tgz#0fa2c64f932917c3433a0ded55363aae37416b7c" 88 | integrity sha512-k/vGaX4/Yla3WzyMCvTQOXYeIHvqOKtnqBduzTHpzpQZzAskKMhZ2K+EnBiSM9zGSoIFeMpXKxa4dYeZIQqewQ== 89 | 90 | is-arrayish@^0.3.1: 91 | version "0.3.2" 92 | resolved "https://registry.yarnpkg.com/is-arrayish/-/is-arrayish-0.3.2.tgz#4574a2ae56f7ab206896fb431eaeed066fdf8f03" 93 | integrity sha512-eVRqCvVlZbuw3GrM63ovNSNAeA1K16kaR/LRY/92w0zxQ5/1YzwblUX652i4Xs9RwAGjW9d9y6X88t8OaAJfWQ== 94 | 95 | is-stream@^2.0.0: 96 | version "2.0.1" 97 | resolved "https://registry.yarnpkg.com/is-stream/-/is-stream-2.0.1.tgz#fac1e3d53b97ad5a9d0ae9cef2389f5810a5c077" 98 | integrity sha512-hFoiJiTl63nn+kstHGBtewWSKnQLpyb155KHheA1l39uvtO9nWIop1p3udqPcUd/xbF1VLMO4n7OI6p7RbngDg== 99 | 100 | kuler@^2.0.0: 101 | version "2.0.0" 102 | resolved "https://registry.yarnpkg.com/kuler/-/kuler-2.0.0.tgz#e2c570a3800388fb44407e851531c1d670b061b3" 103 | integrity sha512-Xq9nH7KlWZmXAtodXDDRE7vs6DU1gTU8zYDHDiWLSip45Egwq3plLHzPn27NgvzL2r1LMPC1vdqh98sQxtqj4A== 104 | 105 | logform@^2.2.0, logform@^2.3.2: 106 | version "2.4.0" 107 | resolved "https://registry.yarnpkg.com/logform/-/logform-2.4.0.tgz#131651715a17d50f09c2a2c1a524ff1a4164bcfe" 108 | integrity sha512-CPSJw4ftjf517EhXZGGvTHHkYobo7ZCc0kvwUoOYcjfR2UVrI66RHj8MCrfAdEitdmFqbu2BYdYs8FHHZSb6iw== 109 | dependencies: 110 | "@colors/colors" "1.5.0" 111 | fecha "^4.2.0" 112 | ms "^2.1.1" 113 | safe-stable-stringify "^2.3.1" 114 | triple-beam "^1.3.0" 115 | 116 | lru-cache@^6.0.0: 117 | version "6.0.0" 118 | resolved "https://registry.yarnpkg.com/lru-cache/-/lru-cache-6.0.0.tgz#6d6fe6570ebd96aaf90fcad1dafa3b2566db3a94" 119 | integrity sha512-Jo6dJ04CmSjuznwJSS3pUeWmd/H0ffTlkXXgwZi+eq1UCmqQwCh+eLsYOYCwY991i2Fah4h1BEMCx4qThGbsiA== 120 | dependencies: 121 | yallist "^4.0.0" 122 | 123 | ms@^2.1.1: 124 | version "2.1.3" 125 | resolved "https://registry.yarnpkg.com/ms/-/ms-2.1.3.tgz#574c8138ce1d2b5861f0b44579dbadd60c6615b2" 126 | integrity sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA== 127 | 128 | neovim@4.10.1: 129 | version "4.10.1" 130 | resolved "https://registry.yarnpkg.com/neovim/-/neovim-4.10.1.tgz#ff8655fdcac9bbb9dabea58847ae0644bafbad2c" 131 | integrity sha512-H46Jl2bh/LAFJsitv2MiIK3oCxvQnEK9t3efNMUUkKzsTYlLIikVxGWVk/vJnHzvxoHYBIRB/KHwPAOm+9UStg== 132 | dependencies: 133 | "@msgpack/msgpack" "^2.7.1" 134 | semver "^7.3.5" 135 | winston "3.3.3" 136 | 137 | one-time@^1.0.0: 138 | version "1.0.0" 139 | resolved "https://registry.yarnpkg.com/one-time/-/one-time-1.0.0.tgz#e06bc174aed214ed58edede573b433bbf827cb45" 140 | integrity sha512-5DXOiRKwuSEcQ/l0kGCF6Q3jcADFv5tSmRaJck/OqkVFcOzutB134KRSfF0xDrL39MNnqxbHBbUUcjZIhTgb2g== 141 | dependencies: 142 | fn.name "1.x.x" 143 | 144 | readable-stream@^3.4.0, readable-stream@^3.6.0: 145 | version "3.6.0" 146 | resolved "https://registry.yarnpkg.com/readable-stream/-/readable-stream-3.6.0.tgz#337bbda3adc0706bd3e024426a286d4b4b2c9198" 147 | integrity sha512-BViHy7LKeTz4oNnkcLJ+lVSL6vpiFeX6/d3oSH8zCW7UxP2onchk+vTGB143xuFjHS3deTgkKoXXymXqymiIdA== 148 | dependencies: 149 | inherits "^2.0.3" 150 | string_decoder "^1.1.1" 151 | util-deprecate "^1.0.1" 152 | 153 | safe-buffer@~5.2.0: 154 | version "5.2.1" 155 | resolved "https://registry.yarnpkg.com/safe-buffer/-/safe-buffer-5.2.1.tgz#1eaf9fa9bdb1fdd4ec75f58f9cdb4e6b7827eec6" 156 | integrity sha512-rp3So07KcdmmKbGvgaNxQSJr7bGVSVk5S9Eq1F+ppbRo70+YeaDxkw5Dd8NPN+GD6bjnYm2VuPuCXmpuYvmCXQ== 157 | 158 | safe-stable-stringify@^2.3.1: 159 | version "2.3.1" 160 | resolved "https://registry.yarnpkg.com/safe-stable-stringify/-/safe-stable-stringify-2.3.1.tgz#ab67cbe1fe7d40603ca641c5e765cb942d04fc73" 161 | integrity sha512-kYBSfT+troD9cDA85VDnHZ1rpHC50O0g1e6WlGHVCz/g+JS+9WKLj+XwFYyR8UbrZN8ll9HUpDAAddY58MGisg== 162 | 163 | semver@^7.3.5: 164 | version "7.3.5" 165 | resolved "https://registry.yarnpkg.com/semver/-/semver-7.3.5.tgz#0b621c879348d8998e4b0e4be94b3f12e6018ef7" 166 | integrity sha512-PoeGJYh8HK4BTO/a9Tf6ZG3veo/A7ZVsYrSA6J8ny9nb3B1VrpkuN+z9OE5wfE5p6H4LchYZsegiQgbJD94ZFQ== 167 | dependencies: 168 | lru-cache "^6.0.0" 169 | 170 | simple-swizzle@^0.2.2: 171 | version "0.2.2" 172 | resolved "https://registry.yarnpkg.com/simple-swizzle/-/simple-swizzle-0.2.2.tgz#a4da6b635ffcccca33f70d17cb92592de95e557a" 173 | integrity sha1-pNprY1/8zMoz9w0Xy5JZLeleVXo= 174 | dependencies: 175 | is-arrayish "^0.3.1" 176 | 177 | stack-trace@0.0.x: 178 | version "0.0.10" 179 | resolved "https://registry.yarnpkg.com/stack-trace/-/stack-trace-0.0.10.tgz#547c70b347e8d32b4e108ea1a2a159e5fdde19c0" 180 | integrity sha1-VHxws0fo0ytOEI6hoqFZ5f3eGcA= 181 | 182 | string_decoder@^1.1.1: 183 | version "1.3.0" 184 | resolved "https://registry.yarnpkg.com/string_decoder/-/string_decoder-1.3.0.tgz#42f114594a46cf1a8e30b0a84f56c78c3edac21e" 185 | integrity sha512-hkRX8U1WjJFd8LsDJ2yQ/wWWxaopEsABU1XfkM8A+j0+85JAGppt16cr1Whg6KIbb4okU6Mql6BOj+uup/wKeA== 186 | dependencies: 187 | safe-buffer "~5.2.0" 188 | 189 | text-hex@1.0.x: 190 | version "1.0.0" 191 | resolved "https://registry.yarnpkg.com/text-hex/-/text-hex-1.0.0.tgz#69dc9c1b17446ee79a92bf5b884bb4b9127506f5" 192 | integrity sha512-uuVGNWzgJ4yhRaNSiubPY7OjISw4sw4E5Uv0wbjp+OzcbmVU/rsT8ujgcXJhn9ypzsgr5vlzpPqP+MBBKcGvbg== 193 | 194 | triple-beam@^1.3.0: 195 | version "1.3.0" 196 | resolved "https://registry.yarnpkg.com/triple-beam/-/triple-beam-1.3.0.tgz#a595214c7298db8339eeeee083e4d10bd8cb8dd9" 197 | integrity sha512-XrHUvV5HpdLmIj4uVMxHggLbFSZYIn7HEWsqePZcI50pco+MPqJ50wMGY794X7AOOhxOBAjbkqfAbEe/QMp2Lw== 198 | 199 | typescript@4.6.3: 200 | version "4.6.3" 201 | resolved "https://registry.yarnpkg.com/typescript/-/typescript-4.6.3.tgz#eefeafa6afdd31d725584c67a0eaba80f6fc6c6c" 202 | integrity sha512-yNIatDa5iaofVozS/uQJEl3JRWLKKGJKh6Yaiv0GLGSuhpFJe7P3SbHZ8/yjAHRQwKRoA6YZqlfjXWmVzoVSMw== 203 | 204 | util-deprecate@^1.0.1: 205 | version "1.0.2" 206 | resolved "https://registry.yarnpkg.com/util-deprecate/-/util-deprecate-1.0.2.tgz#450d4dc9fa70de732762fbd2d4a28981419a0ccf" 207 | integrity sha1-RQ1Nyfpw3nMnYvvS1KKJgUGaDM8= 208 | 209 | winston-transport@^4.4.0: 210 | version "4.5.0" 211 | resolved "https://registry.yarnpkg.com/winston-transport/-/winston-transport-4.5.0.tgz#6e7b0dd04d393171ed5e4e4905db265f7ab384fa" 212 | integrity sha512-YpZzcUzBedhlTAfJg6vJDlyEai/IFMIVcaEZZyl3UXIl4gmqRpU7AE89AHLkbzLUsv0NVmw7ts+iztqKxxPW1Q== 213 | dependencies: 214 | logform "^2.3.2" 215 | readable-stream "^3.6.0" 216 | triple-beam "^1.3.0" 217 | 218 | winston@3.3.3: 219 | version "3.3.3" 220 | resolved "https://registry.yarnpkg.com/winston/-/winston-3.3.3.tgz#ae6172042cafb29786afa3d09c8ff833ab7c9170" 221 | integrity sha512-oEXTISQnC8VlSAKf1KYSSd7J6IWuRPQqDdo8eoRNaYKLvwSb5+79Z3Yi1lrl6KDpU6/VWaxpakDAtb1oQ4n9aw== 222 | dependencies: 223 | "@dabh/diagnostics" "^2.0.2" 224 | async "^3.1.0" 225 | is-stream "^2.0.0" 226 | logform "^2.2.0" 227 | one-time "^1.0.0" 228 | readable-stream "^3.4.0" 229 | stack-trace "0.0.x" 230 | triple-beam "^1.3.0" 231 | winston-transport "^4.4.0" 232 | 233 | yallist@^4.0.0: 234 | version "4.0.0" 235 | resolved "https://registry.yarnpkg.com/yallist/-/yallist-4.0.0.tgz#9bb92790d9c0effec63be73519e11a35019a3a72" 236 | integrity sha512-3wdGidZyq5PB084XLES5TpOSRA3wjXAlIWMhum2kRcv/41Sn2emQ0dycQW4uZXLejwKvg6EsvbdlVL+FYEct7A== 237 | -------------------------------------------------------------------------------- /options.ts: -------------------------------------------------------------------------------- 1 | /* eslint-disable @typescript-eslint/camelcase */ 2 | 3 | import fs from "fs"; 4 | import path from "path"; 5 | 6 | import Git from "nodegit"; 7 | import { bullets } from "nice-comment"; 8 | 9 | import { Termination } from "./util/error"; 10 | import { removeUndefinedProperties } from "./util/removeUndefinedProperties"; 11 | 12 | import { InternalOnlyOptions } from "./internal"; 13 | import { ConfigValues, defaultConfigValues, resolveGitConfigValues } from "./config"; 14 | import { filenames } from "./filenames"; 15 | import { noop } from "./util/noop"; 16 | 17 | /** 18 | * first, the required options: 19 | * without them, GSR cannot function properly. 20 | */ 21 | export type _BaseOptionsForGitStackedRebase_Required = { 22 | initialBranch: string; 23 | 24 | gitDir: string; 25 | 26 | /** 27 | * editor name, or a function that opens the file inside some editor. 28 | */ 29 | editor: string; 30 | 31 | /** 32 | * for executing raw git commands 33 | * that aren't natively supported by `nodegit` (libgit2) 34 | */ 35 | gitCmd: string; 36 | }; 37 | 38 | export type _BaseOptionsForGitStackedRebase_Optional = Partial<{ 39 | gpgSign: boolean; 40 | autoSquash: boolean; 41 | autoApplyIfNeeded: boolean; 42 | autoOpenPRUrlsInBrowser: ConfigValues["autoOpenPRUrlsInBrowser"]; 43 | ignoredBranches: ConfigValues["ignoredBranches"]; 44 | 45 | apply: boolean; 46 | continue: boolean; 47 | push: boolean; 48 | forcePush: boolean; 49 | 50 | branchSequencer: boolean; 51 | branchSequencerExec: string | false; 52 | 53 | pullRequest: boolean; 54 | 55 | repair: boolean; 56 | }>; 57 | 58 | export type ResolvedGitStackedRebaseOptions = Required<_BaseOptionsForGitStackedRebase_Optional> & 59 | _BaseOptionsForGitStackedRebase_Required & 60 | InternalOnlyOptions; 61 | 62 | /** 63 | * the specifiable ones in the library call (all optional) 64 | */ 65 | export type SpecifiableGitStackedRebaseOptions = Partial< 66 | Omit< 67 | ResolvedGitStackedRebaseOptions, 68 | /** some options can be specified thru config, but not as CLI arg: */ 69 | "ignoredBranches" 70 | > 71 | >; 72 | 73 | export const defaultEditor = "vi" as const; 74 | export const defaultGitCmd = "/usr/bin/env git" as const; 75 | 76 | export type ResolveOptionsCtx = { 77 | config: Git.Config; 78 | dotGitDirPath: string; 79 | }; 80 | 81 | export async function resolveOptions( 82 | specifiedOptions: SpecifiableGitStackedRebaseOptions, // 83 | { 84 | config, // 85 | dotGitDirPath, 86 | }: ResolveOptionsCtx 87 | ): Promise { 88 | const resolvedOptions: ResolvedGitStackedRebaseOptions = { 89 | /** 90 | * order matters for what takes priority. 91 | */ 92 | ...getDefaultResolvedOptions(), 93 | ...(await resolveGitConfigValues(config)), 94 | ...removeUndefinedProperties(specifiedOptions), 95 | 96 | /** 97 | * the `initialBranch` arg is taken from `specifiedOptions`, instead of `resolvedOptions`, 98 | * because we do want to throw the error if the user didn't specify & does not have cached. 99 | */ 100 | initialBranch: resolveInitialBranchNameFromProvidedOrCache({ 101 | initialBranch: specifiedOptions.initialBranch, // 102 | dotGitDirPath, 103 | }), 104 | }; 105 | 106 | const reasonsWhatWhyIncompatible: string[] = []; 107 | if (areOptionsIncompatible(resolvedOptions, reasonsWhatWhyIncompatible)) { 108 | const msg = 109 | "\n" + 110 | bullets( 111 | "error - incompatible options:", // 112 | reasonsWhatWhyIncompatible, 113 | " " 114 | ) + 115 | "\n\n"; 116 | throw new Termination(msg); 117 | } 118 | 119 | return resolvedOptions; 120 | } 121 | 122 | export const getDefaultResolvedOptions = (): ResolvedGitStackedRebaseOptions => ({ 123 | initialBranch: "origin/master", 124 | // 125 | gitDir: ".", // 126 | gitCmd: process.env.GIT_CMD ?? defaultGitCmd, 127 | editor: process.env.EDITOR ?? defaultEditor, 128 | // 129 | gpgSign: false, 130 | autoSquash: false, 131 | autoApplyIfNeeded: false, 132 | autoOpenPRUrlsInBrowser: defaultConfigValues.autoOpenPRUrlsInBrowser, 133 | ignoredBranches: [], 134 | // 135 | apply: false, 136 | // 137 | continue: false, 138 | // 139 | push: false, 140 | forcePush: false, 141 | // 142 | branchSequencer: false, 143 | branchSequencerExec: false, 144 | // 145 | pullRequest: false, 146 | // 147 | repair: false, 148 | }); 149 | 150 | export function areOptionsIncompatible( 151 | options: ResolvedGitStackedRebaseOptions, // 152 | reasons: string[] = [] 153 | ): boolean { 154 | noop(options); 155 | /** 156 | * TODO HANDLE ALL CASES 157 | */ 158 | 159 | return reasons.length > 0; 160 | } 161 | 162 | export type ResolveInitialBranchNameFromProvidedOrCacheCtx = { 163 | initialBranch?: SpecifiableGitStackedRebaseOptions["initialBranch"]; 164 | dotGitDirPath: string; 165 | }; 166 | 167 | export function resolveInitialBranchNameFromProvidedOrCache({ 168 | initialBranch, // 169 | dotGitDirPath, 170 | }: ResolveInitialBranchNameFromProvidedOrCacheCtx): string { 171 | const pathToStackedRebaseDirInsideDotGit: string = path.join(dotGitDirPath, "stacked-rebase"); 172 | const initialBranchCachePath: string = path.join(pathToStackedRebaseDirInsideDotGit, filenames.initialBranch); 173 | 174 | fs.mkdirSync(pathToStackedRebaseDirInsideDotGit, { recursive: true }); 175 | 176 | const hasCached = () => fs.existsSync(initialBranchCachePath); 177 | const setCache = (initialBranch: string) => fs.writeFileSync(initialBranchCachePath, initialBranch); 178 | const getCache = (): string => fs.readFileSync(initialBranchCachePath).toString(); 179 | 180 | if (initialBranch) { 181 | setCache(initialBranch); 182 | return initialBranch; 183 | } 184 | 185 | if (hasCached()) { 186 | return getCache(); 187 | } else { 188 | /** 189 | * TODO: try from config if default initial branch is specified, 190 | * if yes - check if is here, if yes - ask user if start from there. 191 | * if no - throw 192 | */ 193 | const msg = `\ndefault argument of the initial branch is required.\n\n`; 194 | throw new Termination(msg); 195 | } 196 | } 197 | 198 | export async function parseInitialBranch(repo: Git.Repository, nameOfInitialBranch: string): Promise { 199 | const initialBranch: Git.Reference | void = await Git.Branch.lookup(repo, nameOfInitialBranch, Git.Branch.BRANCH.ALL); 200 | 201 | if (!initialBranch) { 202 | throw new Termination("initialBranch lookup failed"); 203 | } 204 | 205 | return initialBranch; 206 | } 207 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "git-stacked-rebase", 3 | "version": "0.6.1", 4 | "main": "dist/git-stacked-rebase.js", 5 | "types": "dist/git-stacked-rebase.d.ts", 6 | "repository": "git@github.com:kiprasmel/git-stacked-rebase.git", 7 | "author": "Kipras Melnikovas (https://kipras.org/)", 8 | "license": "UNLICENSED", 9 | "bin": { 10 | "git-stacked-rebase": "./dist/git-stacked-rebase.js" 11 | }, 12 | "scripts": { 13 | "install:all": "yarn && yarn --cwd nvim-git-rebase-todo && yarn --cwd git-reconcile-rewritten-list", 14 | "dev": "yarn build:core && yarn tsc -w", 15 | "test": "ts-node-dev ./test/run.ts", 16 | "build": "yarn test && yarn build:all", 17 | "build:all": "node ./script/prebuild.js && yarn build:core && yarn build:nvim && yarn build:reconciler && node ./script/postbuild.js", 18 | "build:core": "yarn tsc -b", 19 | "build:nvim": "yarn --cwd nvim-git-rebase-todo build", 20 | "build:reconciler": "yarn --cwd git-reconcile-rewritten-list build", 21 | "prepack": "yarn build", 22 | "cloc": "./script/cloc.sh" 23 | }, 24 | "devDependencies": { 25 | "@types/node": "16.11.11", 26 | "@types/nodegit": "0.27.10", 27 | "ts-node-dev": "1.1.8", 28 | "typescript": "4.5.2" 29 | }, 30 | "dependencies": { 31 | "nice-comment": "0.9.0", 32 | "nodegit": "0.28.0-alpha.18", 33 | "open": "8.4.2", 34 | "pipestdio": "0.1.3" 35 | } 36 | } 37 | -------------------------------------------------------------------------------- /parse-todo-of-stacked-rebase/parseNewGoodCommands.spec.ts: -------------------------------------------------------------------------------- 1 | /* eslint-disable @typescript-eslint/camelcase */ 2 | 3 | import { gitStackedRebase } from "../git-stacked-rebase"; 4 | import { 5 | humanOpAppendLineAfterNthCommit, // 6 | humanOpRemoveLineOfCommit, 7 | humanOpChangeCommandOfNthCommitInto, 8 | } from "../humanOp"; 9 | import { editor__internal } from "../internal"; 10 | 11 | import { setupRepo } from "../test/util/setupRepo"; 12 | 13 | export async function parseNewGoodCommandsSpec(): Promise { 14 | await succeeds_to_apply_after_break_or_exec(); 15 | await succeeds_to_apply_after_implicit_drop(); 16 | await succeeds_to_apply_after_explicit_drop(); 17 | 18 | async function succeeds_to_apply_after_break_or_exec(): Promise { 19 | const { common, commitsInLatest } = await setupRepo(); 20 | 21 | await gitStackedRebase({ 22 | ...common, 23 | [editor__internal]: ({ filePath }) => { 24 | humanOpAppendLineAfterNthCommit("break", { 25 | filePath, // 26 | commitSHA: commitsInLatest[7], 27 | }); 28 | }, 29 | }); 30 | 31 | await gitStackedRebase({ 32 | ...common, 33 | apply: true, 34 | }); 35 | } 36 | 37 | async function succeeds_to_apply_after_implicit_drop(): Promise { 38 | const { common, commitsInLatest } = await setupRepo(); 39 | 40 | await gitStackedRebase({ 41 | ...common, 42 | [editor__internal]: ({ filePath }) => { 43 | humanOpRemoveLineOfCommit({ 44 | filePath, // 45 | commitSHA: commitsInLatest[7], 46 | }); 47 | }, 48 | }); 49 | 50 | await gitStackedRebase({ 51 | ...common, 52 | apply: true, 53 | }); 54 | } 55 | 56 | async function succeeds_to_apply_after_explicit_drop(): Promise { 57 | const { common, commitsInLatest } = await setupRepo(); 58 | 59 | await gitStackedRebase({ 60 | ...common, 61 | [editor__internal]: ({ filePath }) => { 62 | humanOpChangeCommandOfNthCommitInto("drop", { 63 | filePath, // 64 | commitSHA: commitsInLatest[7], 65 | }); 66 | }, 67 | }); 68 | 69 | await gitStackedRebase({ 70 | ...common, 71 | apply: true, 72 | }); 73 | } 74 | } 75 | -------------------------------------------------------------------------------- /parse-todo-of-stacked-rebase/parseNewGoodCommands.ts: -------------------------------------------------------------------------------- 1 | /* eslint-disable indent */ 2 | 3 | import assert from "assert"; 4 | 5 | import Git from "nodegit"; 6 | import { array } from "nice-comment"; 7 | 8 | import { filenames } from "../filenames"; 9 | // eslint-disable-next-line import/no-cycle 10 | import { readRewrittenListNotAppliedOrAppliedOrError } from "../apply"; 11 | 12 | import { parseTodoOfStackedRebase } from "./parseTodoOfStackedRebase"; 13 | import { 14 | GoodCommand, // 15 | namesOfRebaseCommandsThatWillDisappearFromCommandList, 16 | stackedRebaseCommands, 17 | } from "./validator"; 18 | 19 | import { log } from "../util/log"; 20 | 21 | export function parseNewGoodCommands( 22 | repo: Git.Repository, 23 | pathToStackedRebaseTodoFile: string // 24 | ): GoodCommand[] { 25 | const oldGoodCommands: GoodCommand[] = parseTodoOfStackedRebase(pathToStackedRebaseTodoFile); 26 | 27 | logGoodCmds(oldGoodCommands); 28 | 29 | const { combinedRewrittenListLines } = readRewrittenListNotAppliedOrAppliedOrError(repo.path()); 30 | 31 | const newCommits: { newSHA: string; oldSHAs: string[] }[] = []; 32 | 33 | type OldCommit = { oldSHA: string; newSHA: string; changed: boolean }; 34 | const oldCommits: OldCommit[] = []; 35 | 36 | combinedRewrittenListLines.map((line) => { 37 | const fromToSHA = line.split(" "); 38 | assert( 39 | fromToSHA.length === 2, 40 | `from and to SHAs, coming from ${filenames.rewrittenList}, are written properly (1 space total).` 41 | ); 42 | 43 | const [oldSHA, newSHA] = fromToSHA; 44 | 45 | oldCommits.push({ oldSHA, newSHA, changed: oldSHA !== newSHA }); 46 | 47 | const last = newCommits.length - 1; 48 | 49 | if (newCommits.length && newSHA === newCommits[last].newSHA) { 50 | /** 51 | * accumulating - if multiple commits got molded into 1 52 | */ 53 | newCommits[last].oldSHAs.push(oldSHA); 54 | } else { 55 | /** 56 | * initializing a new commit 57 | */ 58 | newCommits.push({ 59 | newSHA, 60 | oldSHAs: [oldSHA], 61 | }); 62 | } 63 | 64 | // 65 | }); 66 | 67 | log({ newCommits: newCommits.map((c) => c.newSHA + ": " + array(c.oldSHAs)) }); 68 | log({ oldCommits }); 69 | 70 | /** 71 | * match oldCommits & goodCommands 72 | */ 73 | const goodNewCommands: GoodCommand[] = []; 74 | 75 | goodNewCommands.push(oldGoodCommands[0]); 76 | 77 | let lastNewCommit: OldCommit | null = null; 78 | 79 | /** 80 | * TODO FIXME 81 | * 82 | * we're going thru oldCommits and incrementing the `i`, 83 | * even though we jump thru and keep staying at the same `goodCommandMinIndex`. 84 | * 85 | * the oldCommits are from the rewrittenList, 86 | * meaning they only begin from where the first rewrite was done 87 | * (reword/edit/etc), 88 | * 89 | * so iterating thru them to generate a new list of good commands 90 | * ofc is broken. 91 | * 92 | * instead we need to go thru the old __commands__, 93 | * whom come from the old git-rebase-todo file (of stacked rebase), 94 | * and use the oldCommits/newCommits to re-generate the rebase todo, 95 | * but now adjusted to the commits that have been rewritten. 96 | * 97 | */ 98 | let goodCommandMinIndex = 1; 99 | for (let i = 0; i < oldCommits.length; i++) { 100 | const oldCommit: OldCommit = oldCommits[i]; 101 | 102 | const oldCommandAtIdx: GoodCommand = oldGoodCommands[goodCommandMinIndex]; 103 | 104 | if (namesOfRebaseCommandsThatWillDisappearFromCommandList.includes(oldCommandAtIdx.commandName)) { 105 | goodCommandMinIndex++; // the command should disappear, 106 | i--; // but the commit should not be lost. 107 | 108 | continue; 109 | } 110 | 111 | if (oldCommandAtIdx.commandName in stackedRebaseCommands) { 112 | goodNewCommands.push({ 113 | ...oldCommandAtIdx, 114 | commitSHAThatBranchPointsTo: (lastNewCommit as OldCommit | null)?.newSHA ?? null, // TODO TS 115 | } as any); // TODO TS 116 | goodCommandMinIndex++; 117 | } 118 | 119 | const goodOldCommand = oldGoodCommands.find((cmd) => cmd.targets?.[0] === oldCommit.oldSHA); 120 | 121 | if (!goodOldCommand) { 122 | throw new Error("TODO: goodCommandOld not found"); 123 | } 124 | 125 | const update = () => { 126 | if (goodOldCommand.commandName in stackedRebaseCommands) { 127 | // do not modify 128 | /** TODO FIXME CLEANUP: this actually never happens: (add `assert(false)`) */ 129 | goodNewCommands.push(goodOldCommand); 130 | // goodCommandMinIndex++; 131 | } else { 132 | // goodNewCommands.push({ ...goodOldCommand, targets: [oldCommit.newSHA] /** TODO VERIFY */ }); 133 | lastNewCommit = oldCommit; 134 | goodNewCommands.push({ ...goodOldCommand, targets: [oldCommit.newSHA] /** TODO VERIFY */ }); 135 | goodCommandMinIndex++; 136 | } 137 | }; 138 | 139 | /** 140 | * TODO `lineNumber` -- shouldn't it be `nthCommand`? 141 | * need to experiment w/ e.g. "break", and comments. 142 | */ 143 | if (goodOldCommand.lineNumber < goodCommandMinIndex) { 144 | // TODO VERIFY 145 | const msg = `goodCommandOld.index (${goodOldCommand.lineNumber}) < goodCommandMinIndex (${goodCommandMinIndex}), continue'ing.`; 146 | log(msg); // WARN 147 | 148 | // goodCommandMinIndex++; 149 | 150 | continue; 151 | } else if (goodOldCommand.lineNumber === goodCommandMinIndex) { 152 | // perfect? 153 | // TODO VERIFY 154 | log(`index match`); 155 | 156 | update(); 157 | } else { 158 | // jump? 159 | // TODO VERIFY 160 | log(`jump, continue'ing`); // WARN 161 | 162 | // update(); // TODO VERIFY 163 | continue; 164 | } 165 | 166 | // 167 | } 168 | 169 | goodNewCommands.push(oldGoodCommands[oldGoodCommands.length - 1]); 170 | 171 | log({ 172 | len: oldGoodCommands.length, 173 | goodCommands: oldGoodCommands.map((c) => c.commandOrAliasName + ": " + c.targets?.join(", ") + "."), 174 | }); 175 | 176 | log({ 177 | len: goodNewCommands.length, 178 | goodNewCommands: goodNewCommands.map((c) => c.commandOrAliasName + ": " + c.targets?.join(", ") + "."), 179 | }); 180 | 181 | const stackedRebaseCommandsOld = oldGoodCommands.filter((cmd) => cmd.commandName in stackedRebaseCommands); 182 | const stackedRebaseCommandsNew: GoodCommand[] = goodNewCommands 183 | .map((cmd, i) => 184 | cmd.commandName in stackedRebaseCommands 185 | ? { 186 | ...cmd, 187 | commitSHAThatBranchPointsTo: i > 0 ? goodNewCommands[i - 1].targets?.[0] ?? null : null, 188 | } 189 | : false 190 | ) 191 | .filter((cmd) => !!cmd) as GoodCommand[]; // TODO TS should infer automatically 192 | 193 | log({ 194 | ["stackedRebaseCommandsOld.length"]: stackedRebaseCommandsOld.length, 195 | ["stackedRebaseCommandsNew.length"]: stackedRebaseCommandsNew.length, 196 | }); 197 | 198 | const oldCommandCount: number = stackedRebaseCommandsOld.length; 199 | const newCommandCount: number = stackedRebaseCommandsNew.length; 200 | 201 | assert.equal(oldCommandCount, newCommandCount); 202 | 203 | return stackedRebaseCommandsNew; 204 | } 205 | 206 | const logGoodCmds = (goodCommands: GoodCommand[]): void => { 207 | log({ 208 | goodCommands: goodCommands.map((c) => ({ 209 | ...c, 210 | targets: c.targets?.length === 1 ? c.targets[0] : array(c.targets ?? []), 211 | })), 212 | }); 213 | 214 | log({ 215 | goodCommands: goodCommands.map((c) => c.commandOrAliasName + ": " + c.targets?.join(", ") + "."), 216 | }); 217 | }; 218 | -------------------------------------------------------------------------------- /parse-todo-of-stacked-rebase/parseTodoOfStackedRebase.ts: -------------------------------------------------------------------------------- 1 | /* eslint-disable indent */ 2 | 3 | import fs from "fs"; 4 | 5 | // import path from "path"; 6 | 7 | import { GoodCommand, validate } from "./validator"; 8 | 9 | export function parseTodoOfStackedRebase( 10 | pathToStackedRebaseTodoFile: string // 11 | // goodCommands: GoodCommand[] 12 | ): GoodCommand[] { 13 | const editedRebaseTodo: string = fs.readFileSync(pathToStackedRebaseTodoFile, { encoding: "utf-8" }); 14 | const linesOfEditedRebaseTodo: string[] = editedRebaseTodo.split("\n").filter((line) => !!line); 15 | 16 | return validate(linesOfEditedRebaseTodo); 17 | } 18 | -------------------------------------------------------------------------------- /pullRequestStack.ts: -------------------------------------------------------------------------------- 1 | import Git from "nodegit"; 2 | 3 | import { 4 | CommitAndBranchBoundary, 5 | getWantedCommitsWithBranchBoundariesOurCustomImpl, 6 | removeLocalAndRemoteRefPrefix, 7 | } from "./git-stacked-rebase"; 8 | import { parseGithubRemoteUrl, createGithubURLForStackedPR } from "./adapter-github"; 9 | import { pickRemoteFromRepo } from "./forcePush"; 10 | 11 | import { AskQuestion, askWhichBranchEndToUseForStackedPRs } from "./util/createQuestion"; 12 | import { Triple } from "./util/tuple"; 13 | import { Termination } from "./util/error"; 14 | 15 | export type GenerateListOfURLsToCreateStackedPRsCtx = { 16 | repo: Git.Repository; 17 | initialBranch: Git.Reference; 18 | currentBranch: Git.Reference; 19 | ignoredBranches: string[]; 20 | askQuestion: AskQuestion; 21 | }; 22 | 23 | export async function generateListOfURLsToCreateStackedPRs({ 24 | repo, 25 | initialBranch, 26 | currentBranch, 27 | ignoredBranches, 28 | askQuestion, 29 | }: GenerateListOfURLsToCreateStackedPRsCtx): Promise { 30 | const branchBoundaries: CommitAndBranchBoundary[] = await getWantedCommitsWithBranchBoundariesOurCustomImpl( 31 | repo, 32 | initialBranch, 33 | currentBranch 34 | ); 35 | 36 | const stackedBranchesReadyForStackedPRs: CommitBranch[] = await getStackedBranchesReadyForStackedPRs({ 37 | branchBoundaries, 38 | ignoredBranches, 39 | askQuestion, 40 | }); 41 | 42 | const remoteName: string = await pickRemoteFromRepo(repo, { 43 | cannotDoXWhenZero: "Cannot create pull requests without any remotes.", 44 | pleaseChooseOneFor: "creating pull requests", 45 | }); 46 | 47 | const remote: Git.Remote = await Git.Remote.lookup(repo, remoteName); 48 | 49 | const parsedGithubUrlData = parseGithubRemoteUrl(remote.url()); 50 | 51 | /** 52 | * TODO: 53 | * 54 | * - [ ] check if some PRs already exist? 55 | * - [ ] check if github 56 | * - [ ] check if all branches in the same remote; otherwise ask which one to use 57 | * - [ ] 58 | */ 59 | const githubURLsForCreatingPRs: string[] = []; 60 | let prevBranch: string = stackedBranchesReadyForStackedPRs[0][1]; 61 | 62 | for (const [_commit, branch] of stackedBranchesReadyForStackedPRs.slice(1)) { 63 | const url: string = createGithubURLForStackedPR({ 64 | repoOwner: parsedGithubUrlData.repoOwner, 65 | repo: parsedGithubUrlData.repo, 66 | baseBranch: prevBranch, 67 | newBranch: branch, 68 | }); 69 | 70 | githubURLsForCreatingPRs.push(url); 71 | 72 | prevBranch = branch; 73 | } 74 | 75 | return githubURLsForCreatingPRs; 76 | } 77 | 78 | /** 79 | * --- 80 | */ 81 | 82 | export type CommitBranch = Triple; 83 | 84 | export type GetStackedBranchesReadyForStackedPRsCtx = { 85 | branchBoundaries: CommitAndBranchBoundary[]; 86 | askQuestion: AskQuestion; 87 | ignoredBranches: string[]; 88 | }; 89 | 90 | export async function getStackedBranchesReadyForStackedPRs({ 91 | branchBoundaries, 92 | ignoredBranches, 93 | askQuestion, 94 | }: GetStackedBranchesReadyForStackedPRsCtx): Promise { 95 | const result: CommitBranch[] = []; 96 | 97 | let countOfCommitsWithMultipleBranches: number = 0; 98 | 99 | for (let boundary of branchBoundaries) { 100 | if (!boundary.branchEnd?.length) { 101 | continue; 102 | } 103 | 104 | const commitSha: string = boundary.commit.sha(); 105 | const branchEnds: string[] = boundary.branchEnd 106 | .map((b) => removeLocalAndRemoteRefPrefix(b.name())) // 107 | .filter((b) => !ignoredBranches.some((ignoredBranchSubstr) => b.includes(ignoredBranchSubstr))); 108 | 109 | if (branchEnds.length === 1) { 110 | result.push([commitSha, branchEnds[0], boundary.branchEnd[0]]); 111 | } else { 112 | /** 113 | * >1 branch end, 114 | * thus need the user to pick one. 115 | * 116 | * we need to know which branch to use (only 1), 117 | * because need to know on top of which to stack later PRs. 118 | */ 119 | 120 | const chosenBranch: string = await askWhichBranchEndToUseForStackedPRs({ 121 | branchEnds, // 122 | commitSha, 123 | askQuestion, 124 | nonFirstAsk: countOfCommitsWithMultipleBranches > 0, 125 | }); 126 | const chosenBranchRef: Git.Reference = boundary.branchEnd.find( 127 | (be) => removeLocalAndRemoteRefPrefix(be.name()) === chosenBranch 128 | )!; 129 | 130 | if (!chosenBranchRef) { 131 | const msg = `chosen branch was picked, but it's Git.Reference was not found. likely a bug.`; 132 | throw new Termination(msg); 133 | } 134 | 135 | result.push([commitSha, chosenBranch, chosenBranchRef]); 136 | 137 | countOfCommitsWithMultipleBranches++; 138 | } 139 | } 140 | 141 | return result; 142 | } 143 | -------------------------------------------------------------------------------- /ref-finder.ts: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env ts-node-dev 2 | 3 | const fs = require('fs') 4 | const cp = require('child_process') 5 | const util = require('util') 6 | 7 | import { removeLocalAndRemoteRefPrefix } from "./git-stacked-rebase" 8 | 9 | import { log } from "./util/log" 10 | 11 | export const ignoreTags = (x: RefBase) => x.objtype !== 'tag' 12 | export const ignoreTagLike = (x: RefBase) => !x.refname.startsWith('refs/tags/') 13 | export const ignoreOutsideStack = (x: RefBase) => x.ref_exists_between_latest_and_initial 14 | export const ignoreStash = (x: RefBase) => x.refname !== 'refs/stash' 15 | export const ignoreRemotes = (x: RefBase) => !x.refname.startsWith('refs/remotes/') 16 | export const ignoreIfDoNotNeedRepair = (x: RefBase) => !x.ref_is_directly_part_of_latest_branch 17 | 18 | export const REF_PASSES_AUTO_REPAIR_FILTER = (x: RefBase) => 19 | ignoreTags(x) 20 | && ignoreTagLike(x) 21 | && ignoreOutsideStack(x) 22 | && ignoreStash(x) 23 | /** 24 | * TODO FIXME: 25 | * for now, ignore remote branches. 26 | * will need to handle divergence between local & remote later. 27 | */ 28 | && ignoreRemotes(x) 29 | && ignoreIfDoNotNeedRepair(x) 30 | 31 | export const execAsync = util.promisify(cp.exec) 32 | export const exec = async (cmd: string, extra = {}) => (await execAsync(cmd, { encoding: 'utf-8', ...extra })).stdout.trim() 33 | export const mergeBase = async (a: string, b: string, extra: string = '') => await exec(`git merge-base ${extra} ${a} ${b}`) 34 | 35 | export const gitRefFormat = "%(objectname) %(objecttype) %(refname)" 36 | export type GitRefOutputLine = [string, string, string] 37 | 38 | /** 39 | * TODO: 40 | * 1. go thru all version of latest branch in the stack, to check if 41 | * lost partial branch has pointed to it, just a previous version 42 | * (means still in the stack, highly likely). 43 | * 44 | * TODO: 45 | * 2. go thru all versions of all partial branches in the stack, 46 | * to check if lost partial branch has pointed to any of them, 47 | * just a previous version 48 | * (still somewhat likely that still belongs to the stack). 49 | * 50 | * TODO: 51 | * 3. cache data for 1. and later 2., so that it doesn't take forever to compute. 52 | * 53 | */ 54 | export async function refFinder({ 55 | INITIAL_BRANCH = "master", 56 | INITIAL_BRANCH_COMMIT = '', 57 | LATEST_BRANCH = '', 58 | // LATEST_BRANCH_COMMIT = '', 59 | } = {}) { 60 | process.on("unhandledRejection", (e) => { 61 | console.error(e) 62 | process.exit(1) 63 | }) 64 | 65 | if (!INITIAL_BRANCH_COMMIT) INITIAL_BRANCH_COMMIT = await exec(`git rev-parse "${INITIAL_BRANCH}"`) 66 | if (!LATEST_BRANCH) LATEST_BRANCH = await exec(`git branch --show`) 67 | // if (!LATEST_BRANCH_COMMIT) LATEST_BRANCH_COMMIT = await exec(`git rev-parse "${LATEST_BRANCH}"`) 68 | 69 | const iniBranchInfo = "initial branch:\n" + INITIAL_BRANCH + "\n" + INITIAL_BRANCH_COMMIT + "\n\n" 70 | log(iniBranchInfo) 71 | 72 | const STDIN: GitRefOutputLine[] = (await exec(`git for-each-ref --format="${gitRefFormat}"`)) 73 | .split('\n') 74 | .slice(0, -1) 75 | .map((x: string): GitRefOutputLine => x.split(' ') as GitRefOutputLine) 76 | 77 | const REF_PROMISES = STDIN.map(x => processRef(x, { 78 | INITIAL_BRANCH, 79 | INITIAL_BRANCH_COMMIT, 80 | LATEST_BRANCH, 81 | // LATEST_BRANCH_COMMIT, 82 | })) 83 | 84 | const ALL_REF_DATA: ProcessedRef[] = await Promise.all(REF_PROMISES) 85 | const REF_DATA: RepairableRef[] = (ALL_REF_DATA 86 | .filter(r => r.can_be_auto_repaired) as RepairableRef[]) 87 | 88 | //.filter(x => !x.ref_exists_between_latest_and_initial) // test 89 | //.filter(x => x.merge_base_to_initial_is_initial_branch && !x.ref_exists_between_latest_and_initial) // test 90 | 91 | // console.log(REF_DATA.map(x => Object.values(x).join(' '))) 92 | //console.log(REF_DATA) 93 | 94 | log(REF_DATA.length) 95 | 96 | const _ = require('lodash') 97 | 98 | const REF_DATA_BY_COMMIT = _.groupBy(REF_DATA, 'commit') 99 | //console.log(REF_DATA_BY_COMMIT) 100 | 101 | fs.writeFileSync('refout.json', JSON.stringify(REF_DATA_BY_COMMIT, null, 2), { encoding: 'utf-8' }) 102 | fs.writeFileSync('refout.all.json', JSON.stringify(ALL_REF_DATA, null, 2), { encoding: 'utf-8' }) 103 | 104 | //COMMIT_DATA_IN_LATEST_BRANCH = 105 | 106 | return REF_DATA 107 | } 108 | 109 | export type RefBase = { 110 | commit: string; 111 | objtype: string; 112 | refname: string; 113 | refnameshort: string; 114 | merge_base_to_initial: string; 115 | merge_base_to_initial_is_initial_branch: boolean; 116 | merge_base_to_latest: string; 117 | merge_base_to_latest_to_initial: string; 118 | ref_exists_between_latest_and_initial: boolean; 119 | ref_is_directly_part_of_latest_branch: boolean; 120 | } 121 | 122 | export type RepairableRef = RefBase & { 123 | range_diff_between_ref__base_to_latest__head__base_to_latest: string[]; 124 | range_diff_parsed: RangeDiff[]; 125 | easy_repair_scenario: EasyScenarioRet; 126 | } 127 | 128 | export type ProcessedRef = (RefBase & { can_be_auto_repaired: false }) | (RepairableRef & { can_be_auto_repaired: true }) 129 | 130 | export type ProcessRefArgs = { 131 | INITIAL_BRANCH: string; 132 | INITIAL_BRANCH_COMMIT: string; 133 | LATEST_BRANCH: string; 134 | } 135 | 136 | export async function processRef(x: GitRefOutputLine, { 137 | INITIAL_BRANCH, // 138 | INITIAL_BRANCH_COMMIT, 139 | LATEST_BRANCH, 140 | // LATEST_BRANCH_COMMIT, 141 | }: ProcessRefArgs): Promise { 142 | const refCommit = x[0] 143 | const objtype = x[1] 144 | const refname = x[2] 145 | 146 | const merge_base_to_initial = await mergeBase(refCommit, INITIAL_BRANCH) 147 | const merge_base_to_initial_is_initial_branch = merge_base_to_initial === INITIAL_BRANCH_COMMIT 148 | 149 | const merge_base_to_latest = await mergeBase(refCommit, LATEST_BRANCH) 150 | const merge_base_to_latest_to_initial = await mergeBase(merge_base_to_latest, INITIAL_BRANCH) 151 | 152 | /** the main thing we're looking for: */ 153 | const ref_exists_between_latest_and_initial = 154 | merge_base_to_latest_to_initial === INITIAL_BRANCH_COMMIT 155 | && merge_base_to_latest !== INITIAL_BRANCH_COMMIT /** if merge base from ref to latest is initial, then ref does not exist inside latest. */ 156 | 157 | /** 158 | * if directly part of latest branch, then is inside the stack & has not diverged, 159 | * thus no repairs are needed (to integrate it into the stack / latest branch) 160 | */ 161 | const ref_is_directly_part_of_latest_branch = 162 | ref_exists_between_latest_and_initial && 163 | merge_base_to_latest === refCommit 164 | 165 | const ref_base: RefBase = { 166 | commit: refCommit, 167 | objtype, 168 | refname, 169 | refnameshort: removeLocalAndRemoteRefPrefix(refname), 170 | merge_base_to_initial, 171 | merge_base_to_initial_is_initial_branch, 172 | merge_base_to_latest, 173 | merge_base_to_latest_to_initial, 174 | ref_exists_between_latest_and_initial, 175 | ref_is_directly_part_of_latest_branch, 176 | } 177 | 178 | const can_be_auto_repaired: boolean = REF_PASSES_AUTO_REPAIR_FILTER(ref_base) 179 | 180 | if (!can_be_auto_repaired) { 181 | /** 182 | * will not be used as an auto-repairable ref, 183 | * thus optimize perf by not computing the range diff et al. 184 | */ 185 | (ref_base as ProcessedRef).can_be_auto_repaired = can_be_auto_repaired 186 | return ref_base as ProcessedRef 187 | 188 | } 189 | 190 | const range_diff_cmd = `git range-diff ${refname}...${merge_base_to_latest} HEAD...${merge_base_to_latest}` 191 | const range_diff_between_ref__base_to_latest__head__base_to_latest: string[] = await exec(range_diff_cmd).then(processRangeDiff) 192 | 193 | const range_diff_parsed_base: RangeDiffBase[] = parseRangeDiff(range_diff_between_ref__base_to_latest__head__base_to_latest) 194 | const range_diff_parsed: RangeDiff[] = await enhanceRangeDiffsWithFullSHAs(range_diff_parsed_base) 195 | 196 | const easy_repair_scenario: EasyScenarioRet = checkIfIsEasyScenarioWhenCanAutoGenerateRewrittenList(range_diff_parsed) 197 | 198 | const ref: RepairableRef = Object.assign(ref_base, { 199 | range_diff_between_ref__base_to_latest__head__base_to_latest, 200 | easy_repair_scenario, 201 | range_diff_parsed, 202 | }) 203 | 204 | log([ 205 | merge_base_to_initial, 206 | merge_base_to_latest_to_initial, 207 | merge_base_to_latest, 208 | can_be_auto_repaired, 209 | ref_is_directly_part_of_latest_branch, 210 | '\n', 211 | ].join(' ')); 212 | 213 | (ref as ProcessedRef).can_be_auto_repaired = can_be_auto_repaired 214 | return (ref as ProcessedRef) 215 | } 216 | 217 | export const processRangeDiff = (x: string): string[] => x.trim().split('\n').map((x: string) => x.trim()) 218 | 219 | export type RangeDiffBase = { 220 | nth_before: string; 221 | sha_before: string; 222 | eq_sign: string; 223 | nth_after: string; 224 | sha_after: string; 225 | msg: string; 226 | diff_lines: string[]; 227 | } 228 | 229 | /** 230 | * head = 9563f3a77c1d86093447893e6538e34d1a18dfd2 231 | * argv-parser-rewrite = f11914d1de05863fac52077a269e66590d92e319 232 | * 233 | * ```sh 234 | * MERGE_BASE=094cddc223e8de5926dbc810449373e614d4cdef git range-diff argv-parser-rewrite...$MERGE_BASE HEAD...$MERGE_BASE 235 | * ``` 236 | */ 237 | export const parseRangeDiff = (lines: string[]): RangeDiffBase[] => { 238 | if (!lines.length || (lines.length === 1 && !lines[0])) { 239 | return [] 240 | } 241 | 242 | const range_diffs: RangeDiffBase[] = [] 243 | 244 | for (let i = 0; i < lines.length; i++) { 245 | const line = replaceManySpacesToOne(lines[i]) 246 | 247 | const [nth_before, tmp1, ...tmp2s] = line.split(":").map((x, i) => i < 2 ? x.trim() : x) 248 | const [sha_after, eq_sign, nth_after] = tmp1.split(" ") 249 | const [sha_before, ...msgs] = tmp2s.join(":").trim().split(" ") 250 | const msg = msgs.join(" ") 251 | 252 | const diff_lines: string[] = [] 253 | 254 | if (eq_sign === "!") { 255 | while (++i < lines.length && !isNewRangeDiffLine(lines[i], nth_before)) { 256 | diff_lines.push(lines[i]) 257 | } 258 | 259 | --i 260 | } 261 | 262 | const range_diff: RangeDiffBase = { 263 | nth_before, 264 | sha_before, 265 | eq_sign, 266 | nth_after, 267 | sha_after, 268 | msg, 269 | diff_lines, 270 | } 271 | 272 | range_diffs.push(range_diff) 273 | } 274 | 275 | return range_diffs 276 | } 277 | 278 | export type RangeDiff = RangeDiffBase & { 279 | sha_before_full: string; 280 | sha_after_full: string; 281 | } 282 | 283 | export const enhanceRangeDiffsWithFullSHAs = async (range_diffs: RangeDiffBase[]): Promise => { 284 | const short_shas: string[] = range_diffs.map(r => [r.sha_before, r.sha_after]).flat() 285 | const short_shas_list: string = short_shas.join(" ") 286 | const full_shas: string[] = await exec(`git rev-parse ${short_shas_list}`).then(x => x.split("\n")) 287 | 288 | let ret: RangeDiff[] = [] 289 | let shas_idx = 0 290 | for (let i = 0; i < range_diffs.length; i++) { 291 | ret.push({ 292 | ...range_diffs[i], 293 | sha_before_full: full_shas[shas_idx++], 294 | sha_after_full: full_shas[shas_idx++] 295 | }) 296 | } 297 | 298 | return ret 299 | } 300 | 301 | /** 302 | * TODO FIXME: can affect commit msg 303 | */ 304 | export const replaceManySpacesToOne = (x: string) => x.replace(/\s+/g, " ") 305 | 306 | export const isNewRangeDiffLine = (line: string, nth_before: string) => { 307 | const nth_before_num = Number(nth_before) 308 | 309 | if (Number.isNaN(nth_before_num)) { 310 | return false 311 | } 312 | 313 | const next: number = nth_before_num + 1 314 | const expectedStart = `${next}: ` 315 | 316 | return line.startsWith(expectedStart) 317 | } 318 | 319 | export type EasyScenarioRet = { 320 | is_easy_repair_scenario: boolean; 321 | // 322 | eq_from: number; 323 | eq_till: number; 324 | eq_count: number; 325 | // 326 | ahead_from: number; 327 | ahead_till: number; 328 | ahead_count: number; 329 | // 330 | behind_from: number; 331 | behind_till: number; 332 | behind_count: number; 333 | } 334 | 335 | export const checkIfIsEasyScenarioWhenCanAutoGenerateRewrittenList = (range_diffs: RangeDiff[]): EasyScenarioRet => { 336 | let i = 0 337 | const eq_from = i 338 | while (i < range_diffs.length && range_diffs[i].eq_sign === "=") { 339 | ++i 340 | } 341 | const eq_till = i 342 | const eq_count = eq_till - eq_from 343 | 344 | // extra commits in diverged branch, that need to be integrated back into latest 345 | const ahead_from = i 346 | while (i < range_diffs.length && range_diffs[i].eq_sign === "<") { 347 | ++i 348 | } 349 | const ahead_till = i 350 | const ahead_count = ahead_till - ahead_from 351 | 352 | const behind_from = i 353 | while (i < range_diffs.length && range_diffs[i].eq_sign === ">") { 354 | ++i 355 | } 356 | const behind_till = i 357 | const behind_count = behind_till - behind_from 358 | 359 | const is_easy_repair_scenario = i === range_diffs.length 360 | 361 | return { 362 | is_easy_repair_scenario, 363 | // 364 | eq_from, 365 | eq_till, 366 | eq_count, 367 | // 368 | ahead_from, 369 | ahead_till, 370 | ahead_count, 371 | // 372 | behind_from, 373 | behind_till, 374 | behind_count, 375 | } 376 | } 377 | 378 | if (!module.parent) { 379 | refFinder() 380 | } 381 | -------------------------------------------------------------------------------- /repair.ts: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env ts-node-dev 2 | 3 | import assert from "assert"; 4 | 5 | import Git from "nodegit"; 6 | 7 | import { CommitAndBranchBoundary, gitStackedRebase, referenceToOid } from "./git-stacked-rebase"; 8 | import { askQuestion__internal } from "./internal"; 9 | import { RangeDiff, RepairableRef, refFinder } from "./ref-finder"; 10 | 11 | import { AskQuestion, question } from "./util/createQuestion"; 12 | import { Termination } from "./util/error"; 13 | import { log } from "./util/log"; 14 | import { stdout } from "./util/stdout"; 15 | 16 | export type RepairCtx = { 17 | initialBranch: Git.Reference; 18 | currentBranch: Git.Reference; 19 | askQuestion: AskQuestion; 20 | commitsWithBranchBoundaries: CommitAndBranchBoundary[]; 21 | repo: Git.Repository; 22 | } 23 | 24 | export async function repair({ 25 | initialBranch, 26 | currentBranch, 27 | askQuestion, 28 | commitsWithBranchBoundaries, 29 | repo, 30 | }: RepairCtx): Promise { 31 | const initialBranchCommit: string = await referenceToOid(initialBranch).then(x => x.tostrS()); 32 | 33 | const autoRepairableRefs: RepairableRef[] = await findAutoRepairableRefs({ 34 | initialBranch: initialBranch.name(), 35 | initialBranchCommit, 36 | latestBranch: currentBranch.name(), 37 | askQuestion, 38 | }); 39 | 40 | log({ autoRepairableRefs }); 41 | 42 | const ref_repaired_sha_index: Map = new Map(autoRepairableRefs.map(r => [r.refname, 0])); 43 | const refs_in_progress: Set = new Set(); 44 | 45 | for (let i = 0; i < commitsWithBranchBoundaries.length; i++) { 46 | const bb = commitsWithBranchBoundaries[i]; 47 | const bb_commit_sha: string = bb.commit.sha(); 48 | 49 | let added_new_commits = 0; 50 | 51 | const insertCommit = (newCommit: CommitAndBranchBoundary): void => void commitsWithBranchBoundaries.splice(i + (++added_new_commits), 0, newCommit); 52 | 53 | /** 54 | * either has not been replaced yet, 55 | * 56 | * or if has been replaced already, 57 | * then the replacement commit must match in all refs that need replacing; 58 | * otherwise undefined behavior. 59 | * 60 | */ 61 | let current_commit_has_been_replaced_by_sha: string | null = null; 62 | const refs_repairing_current_sha: Map = new Map(); // DEBUG 63 | 64 | for (const ref of autoRepairableRefs) { 65 | const { refname } = ref; 66 | let repair_nth_sha: number = ref_repaired_sha_index.get(refname)!; 67 | const incr_ref_sha_index = () => ref_repaired_sha_index.set(refname, ++repair_nth_sha); 68 | 69 | const ref_already_finished: boolean = repair_nth_sha === ref.easy_repair_scenario.eq_count + 1; 70 | if (ref_already_finished) { 71 | continue; 72 | } 73 | 74 | const delta: RangeDiff = ref.range_diff_parsed[repair_nth_sha]; 75 | const old_sha_to_find: string = delta.sha_before_full; 76 | 77 | const found_sha: boolean = bb_commit_sha === old_sha_to_find; 78 | 79 | if (!found_sha) { 80 | if (refs_in_progress.has(refname)) { 81 | const msg = `\nref "${refname}" repair was in progress, reached repair index ${repair_nth_sha}, but did not find matching SHA in latest.\n\n`; 82 | throw new Termination(msg); 83 | } else { 84 | continue; 85 | } 86 | } else { 87 | if (!refs_in_progress.has(refname)) { 88 | refs_in_progress.add(refname); 89 | 90 | /** 91 | * TODO: insert comment that it's the start of repairment of `ref` 92 | */ 93 | } 94 | refs_repairing_current_sha.set(refname, delta.sha_after_full); 95 | 96 | if (!current_commit_has_been_replaced_by_sha) { 97 | /** drop the current commit */ 98 | bb.commitCommand = "drop"; 99 | 100 | /** 101 | * drop the branchEnd -- will get a new one assigned 102 | * from the diverged branch (once done) 103 | */ 104 | bb.branchEnd = null; 105 | 106 | /** add new */ 107 | insertCommit({ 108 | commit: await Git.Commit.lookup(repo, delta.sha_after_full), 109 | commitCommand: "pick", 110 | branchEnd: null 111 | }); 112 | 113 | /** mark as added */ 114 | current_commit_has_been_replaced_by_sha = delta.sha_after_full; 115 | } else { 116 | /** verify that the replacement sha is the same as we have for replacement. */ 117 | const replaced_sha_is_same_as_our_replacement = current_commit_has_been_replaced_by_sha === delta.sha_after_full; 118 | 119 | if (!replaced_sha_is_same_as_our_replacement) { 120 | const old_sha = `old sha: ${old_sha_to_find}`; 121 | const repairing_refs = [...refs_repairing_current_sha.entries()]; 122 | const longest_refname: number = repairing_refs.map(([name]) => name.length).reduce((acc, curr) => Math.max(acc, curr), 0); 123 | 124 | const progress = repairing_refs.map(([name, sha]) => name.padEnd(longest_refname, " ") + ": " + sha).join("\n"); 125 | const msg = `\nmultiple refs want to repair the same SHA, but their resulting commit SHAs differ:\n\n` + old_sha + "\n\n" + progress + "\n\n"; 126 | 127 | throw new Termination(msg); 128 | } 129 | } 130 | 131 | incr_ref_sha_index(); 132 | } 133 | 134 | const just_finished_ref: boolean = repair_nth_sha === ref.easy_repair_scenario.eq_count; 135 | 136 | if (just_finished_ref) { 137 | refs_in_progress.delete(refname); 138 | incr_ref_sha_index(); // mark as done 139 | 140 | /** 141 | * insert extra commits 142 | * 143 | * TODO: if multiple refs, is this good? 144 | * 145 | * because then, ref order matters.. 146 | * & could get merge conflicts 147 | * 148 | */ 149 | if (ref.easy_repair_scenario.ahead_count) { 150 | for (let delta_idx = ref.easy_repair_scenario.ahead_from; delta_idx < ref.easy_repair_scenario.ahead_till; delta_idx++) { 151 | const delta = ref.range_diff_parsed[delta_idx]; 152 | 153 | const extraCommit: CommitAndBranchBoundary = { 154 | commit: await Git.Commit.lookup(repo, delta.sha_after_full), 155 | commitCommand: "pick", 156 | branchEnd: null, 157 | }; 158 | 159 | insertCommit(extraCommit); 160 | } 161 | } 162 | 163 | /** 164 | * add the branchEnd to the latest commit. 165 | * 166 | * note: previous commits (which are now replaced) might've had branchEnds - 167 | * those branchEnds have been removed in the repair process. 168 | * 169 | * if there's some branchEnds on the commit, 170 | * they're coming from other refs. 171 | */ 172 | const latest_commit_idx = i + added_new_commits; 173 | 174 | if (!commitsWithBranchBoundaries[latest_commit_idx].branchEnd) { 175 | commitsWithBranchBoundaries[latest_commit_idx].branchEnd = []; 176 | } 177 | 178 | const adjustedBranchEnd: Git.Reference = await Git.Branch.lookup(repo, ref.refnameshort, Git.Branch.BRANCH.ALL); 179 | commitsWithBranchBoundaries[latest_commit_idx].branchEnd!.push(adjustedBranchEnd); 180 | 181 | // TODO: add comment that finished repairing ref 182 | // tho, prolly pretty obvious since the new branch-end will be there? 183 | continue; 184 | } 185 | } 186 | 187 | i += added_new_commits; 188 | } 189 | 190 | assert.deepStrictEqual(refs_in_progress.size, 0, `expected all refs to have finished repairing, but ${refs_in_progress.size} are still in progress.\n`); 191 | 192 | log({ commitsWithBranchBoundaries }); 193 | } 194 | 195 | export type FindAutoRepairableRefsCtx = { 196 | initialBranch: string; 197 | initialBranchCommit: string; 198 | latestBranch: string; 199 | askQuestion: AskQuestion; 200 | } 201 | 202 | export async function findAutoRepairableRefs({ 203 | initialBranch, 204 | initialBranchCommit, 205 | latestBranch, 206 | askQuestion, 207 | }: FindAutoRepairableRefsCtx): Promise { 208 | stdout(`finding repairable refs...\n`) 209 | const candidateRefs: RepairableRef[] = await refFinder({ 210 | INITIAL_BRANCH: initialBranch, 211 | INITIAL_BRANCH_COMMIT: initialBranchCommit, 212 | LATEST_BRANCH: latestBranch, 213 | }) 214 | 215 | const autoRepairableRefs: RepairableRef[] = [] 216 | const nonAutoRepairableRefs: RepairableRef[] = [] 217 | 218 | for (const ref of candidateRefs) { 219 | const isAutoRepairable: boolean = ref.easy_repair_scenario.is_easy_repair_scenario 220 | 221 | if (isAutoRepairable) { 222 | autoRepairableRefs.push(ref) 223 | } else { 224 | nonAutoRepairableRefs.push(ref) 225 | } 226 | } 227 | 228 | stdout(`${candidateRefs.length} candidates found.\n`) 229 | 230 | if (nonAutoRepairableRefs.length) { 231 | stdout(`\n${nonAutoRepairableRefs.length} refs that cannot be auto-repaired:\n`) 232 | stdout(nonAutoRepairableRefs.map(r => r.refname).join("\n") + "\n") 233 | } else { 234 | stdout(`\n0 refs that cannot be auto-repaired.`) 235 | } 236 | 237 | if (!autoRepairableRefs.length) { 238 | const msg = `\nnothing to do: 0 auto-repairable refs found. exiting.\n\n` 239 | throw new Termination(msg, 0) 240 | } 241 | 242 | stdout(`\n${autoRepairableRefs.length} refs that can be auto-repaired:\n`) 243 | 244 | for (let i = 0; i < autoRepairableRefs.length; i++) { 245 | const ref = autoRepairableRefs[i] 246 | 247 | const nth = i + 1 248 | const nth_str = nth.toString().padStart(autoRepairableRefs.length.toString().length, " ") 249 | const nth_info = ` ${nth_str}` 250 | 251 | stdout(`${nth_info} ${ref.refname}\n`) 252 | } 253 | 254 | const q = `\nRepair all? [Y/n/] ` 255 | const ans: string = (await askQuestion(q)).trim().toLowerCase() 256 | 257 | const choices: string[] = ans.replace(/\s+/g, ",").split(",") 258 | let allowedIndices: number[] | null = null 259 | 260 | const refAllowedToBeRepaired = (idx: number): boolean => { 261 | if (!ans || ans === "y") return true 262 | if (ans === "n") return false 263 | 264 | if (!choices.length || (choices.length === 1 && !choices[0].trim())) return false 265 | 266 | if (!allowedIndices) { 267 | allowedIndices = parseChoicesRanges(choices) 268 | } 269 | 270 | return allowedIndices.includes(idx) 271 | } 272 | 273 | const allowedToRepairRefs: RepairableRef[] = autoRepairableRefs.filter((_, i) => refAllowedToBeRepaired(i)) 274 | return allowedToRepairRefs 275 | } 276 | 277 | export function parseChoicesRanges(choices: string[]) { 278 | const allowed: number[] = [] 279 | 280 | for (const choice of choices) { 281 | const isRange = choice.includes("-") 282 | 283 | if (isRange) { 284 | const choicesNum: number[] = choice.split("-").map(Number) 285 | 286 | if (choicesNum.length !== 2) throw new Termination(`\ninvalid format "${choice}".\n\n`) 287 | if (choicesNum.some(x => Number.isNaN(x))) throw new Termination(`\ninvalid format "${choice}" - not a number found.\n\n`) 288 | 289 | const [from, to] = choicesNum 290 | 291 | for (let i = from; i <= to; i++) { 292 | allowed.push(i) 293 | } 294 | } else { 295 | const choiceNum: number = Number(choice) 296 | if (Number.isNaN(choiceNum)) throw new Termination(`\ninvalid format "${choice}" - not a number.\n\n`) 297 | allowed.push(choiceNum) 298 | } 299 | } 300 | 301 | const allowedIndices = allowed.map(x => x - 1) 302 | return allowedIndices 303 | } 304 | 305 | if (!module.parent) { 306 | if (process.env.GSR_DEBUG || process.env.GSR_DEBUG_REPAIR) { 307 | gitStackedRebase({ 308 | initialBranch: "origin/master", 309 | repair: true, 310 | [askQuestion__internal]: (q, ...rest) => { 311 | if (q.includes("Repair all?")) { 312 | return "y" /** can modify easily here */ 313 | } 314 | 315 | return question(q, ...rest) 316 | } 317 | }) 318 | } else { 319 | gitStackedRebase({ 320 | initialBranch: "origin/master", 321 | repair: true, 322 | }) 323 | } 324 | } 325 | -------------------------------------------------------------------------------- /script/cloc.sh: -------------------------------------------------------------------------------- 1 | #!/bin/sh 2 | 3 | cloc . --vcs=git 4 | -------------------------------------------------------------------------------- /script/cloc.ts.sh: -------------------------------------------------------------------------------- 1 | #!/bin/sh 2 | 3 | cloc . --vcs=git --by-file-by-lang --include-lang=typescript,javascript 4 | 5 | -------------------------------------------------------------------------------- /script/postbuild.js: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env node 2 | 3 | const fs = require("fs"); 4 | const { execSync } = require("child_process"); 5 | const { execSyncP } = require("pipestdio"); 6 | 7 | modifyLinesGSR(); 8 | modifyLinesRefFinder(); 9 | 10 | /** 11 | * general util for modifying lines 12 | */ 13 | function modifyLines(exePath, linesCb) { 14 | fs.chmodSync(exePath, "755"); 15 | // execSyncP(`ls -la ${exePath}`); 16 | 17 | const file = fs.readFileSync(exePath, { encoding: "utf-8" }); 18 | const lines = file.split("\n"); 19 | 20 | const afterModificationCb = linesCb(lines); 21 | 22 | const newFile = lines.join("\n"); 23 | fs.writeFileSync(exePath, newFile); 24 | 25 | if (afterModificationCb instanceof Function) afterModificationCb(); 26 | } 27 | 28 | function modifyLinesGSR() { 29 | const exePath = "./dist/git-stacked-rebase.js"; 30 | 31 | modifyLines(exePath, (lines) => { 32 | const afterUpdateShebang = updateShebang(lines, exePath); 33 | const afterInjectRelativeBuildDatePrinter = injectRelativeBuildDatePrinter(lines, exePath); 34 | const afterInjectVersionStr = injectVersionStr(lines); 35 | 36 | return () => { 37 | afterUpdateShebang(); 38 | afterInjectRelativeBuildDatePrinter(); 39 | afterInjectVersionStr(); 40 | }; 41 | }); 42 | } 43 | 44 | function modifyLinesRefFinder() { 45 | const exePath = "./dist/ref-finder.js"; 46 | 47 | modifyLines(exePath, (lines) => { 48 | const afterUpdateShebang = updateShebang(lines, exePath); 49 | 50 | return () => { 51 | afterUpdateShebang(); 52 | }; 53 | }); 54 | } 55 | 56 | function updateShebang(lines, exePath) { 57 | const oldShebang = "#!/usr/bin/env ts-node-dev"; 58 | const newShebang = "#!/usr/bin/env node"; 59 | 60 | if (lines[0].includes(oldShebang)) { 61 | lines[0] = newShebang; 62 | } else if (!lines[0].includes(newShebang)) { 63 | lines.splice(0, 0, newShebang); 64 | } 65 | 66 | return () => { 67 | process.stdout.write(exePath + "\n"); 68 | execSyncP(`cat ${exePath} | head -n 2`); 69 | process.stdout.write("\n"); 70 | }; 71 | } 72 | 73 | function injectRelativeBuildDatePrinter(lines, exePath) { 74 | const BUILD_DATE_REPLACEMENT_STR = "__BUILD_DATE_REPLACEMENT_STR__"; 75 | const targetLineIdx = lines.findIndex((line) => line.includes(BUILD_DATE_REPLACEMENT_STR)); 76 | 77 | const buildDate = new Date().getTime(); 78 | const printRelativeDate = 79 | "(" + // 80 | "built " + 81 | "${" + 82 | `Math.round((new Date() - ${buildDate}) / 1000 / 60)` + 83 | "}" + 84 | " mins ago" + 85 | ")"; 86 | 87 | lines[targetLineIdx] = lines[targetLineIdx].replace(BUILD_DATE_REPLACEMENT_STR, printRelativeDate); 88 | 89 | return () => { 90 | process.stdout.write(exePath + "\n"); 91 | execSyncP(`cat ${exePath} | grep " mins ago"`); 92 | process.stdout.write("\n"); 93 | }; 94 | } 95 | 96 | function injectVersionStr(lines) { 97 | const NEEDLE = "__VERSION_REPLACEMENT_STR__"; 98 | const targetLines = lines.map((line, idx) => [line, idx]).filter(([line]) => line.includes(NEEDLE)); 99 | 100 | if (!targetLines.length) { 101 | throw new Error("0 target lines found."); 102 | } 103 | 104 | const commitSha = execSync("git rev-parse @").toString().trim(); 105 | const hasUntrackedChanges = execSync("git status -s", { encoding: "utf-8" }).toString().length > 0; 106 | 107 | const REPLACEMENT = commitSha + (hasUntrackedChanges ? "-dirty" : ""); 108 | 109 | for (const [_line, idx] of targetLines) { 110 | lines[idx] = lines[idx].replace(NEEDLE, REPLACEMENT); 111 | } 112 | 113 | // return () => execSyncP(`cat ${executablePath} | grep -v ${NEEDLE}`); 114 | return () => void 0; 115 | } 116 | -------------------------------------------------------------------------------- /script/prebuild.js: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env node 2 | 3 | const fs = require("fs"); 4 | const path = require("path"); 5 | 6 | const dir = path.join(__dirname, "..", "dist"); 7 | if (fs.existsSync(dir)) { 8 | fs.rmdirSync(dir, { recursive: true, force: true }); 9 | } 10 | -------------------------------------------------------------------------------- /test/.gitignore: -------------------------------------------------------------------------------- 1 | folders-to-delete 2 | .tmp 3 | .tmp-* 4 | -------------------------------------------------------------------------------- /test/apply.spec.ts: -------------------------------------------------------------------------------- 1 | import assert from "assert"; 2 | import path from "path"; 3 | 4 | import Git from "nodegit"; 5 | 6 | import { configKeys } from "../config"; 7 | import { gitStackedRebase } from "../git-stacked-rebase"; 8 | import { humanOpChangeCommandOfNthCommitInto } from "../humanOp"; 9 | import { askQuestion__internal, editor__internal, noEditor } from "../internal"; 10 | 11 | import { setupRepo } from "./util/setupRepo"; 12 | import { question, Questions } from "../util/createQuestion"; 13 | import { isMarkedThatNeedsToApply } from "../apply"; 14 | 15 | export async function applyTC() { 16 | await integration__git_stacked_rebase_exits_if_apply_was_needed_but_user_disallowed(); 17 | } 18 | 19 | /** 20 | * create a scenario where an apply is needed, and disallow it - GSR should exit. 21 | */ 22 | async function integration__git_stacked_rebase_exits_if_apply_was_needed_but_user_disallowed() { 23 | const { common, commitsInLatest, config, repo } = await setupRepo(); 24 | 25 | /** 26 | * ensure autoApplyIfNeeded is disabled 27 | */ 28 | config.setBool(configKeys.autoApplyIfNeeded, Git.Config.MAP.FALSE); 29 | 30 | /** 31 | * force modify history, so that an apply will be needed 32 | */ 33 | await gitStackedRebase({ 34 | ...common, 35 | [editor__internal]: ({ filePath }) => { 36 | humanOpChangeCommandOfNthCommitInto("drop", { 37 | filePath, // 38 | commitSHA: commitsInLatest[2], 39 | }); 40 | }, 41 | }); 42 | 43 | // TODO take from `gitStackedRebase`: 44 | const dotGitDirPath: string = repo.path(); 45 | const pathToStackedRebaseDirInsideDotGit: string = path.join(dotGitDirPath, "stacked-rebase"); 46 | assert.deepStrictEqual( 47 | isMarkedThatNeedsToApply(pathToStackedRebaseDirInsideDotGit), // 48 | true, 49 | `expected a "needs-to-apply" mark to be set.` 50 | ); 51 | 52 | console.log("performing 2nd rebase, expecting it to throw."); 53 | 54 | const threw: boolean = await didThrow(() => 55 | /** 56 | * perform the rebase again - now that an apply is marked as needed, 57 | * and autoApplyIfNeeded is disabled, 58 | * we should get prompted to allow the apply. 59 | */ 60 | gitStackedRebase({ 61 | ...common, 62 | ...noEditor, 63 | [askQuestion__internal]: (q, ...rest) => { 64 | if (q === Questions.need_to_apply_before_continuing) { 65 | return "n"; 66 | } 67 | 68 | return question(q, ...rest); 69 | }, 70 | }) 71 | ); 72 | 73 | assert.deepStrictEqual( 74 | threw, 75 | true, 76 | `expected 2nd invocation of rebase to throw, because user did not allow to perform a mandatory --apply.\nbut threw = ${threw} (expected true).` 77 | ); 78 | } 79 | 80 | export async function didThrow(fn: Function): Promise { 81 | try { 82 | await fn(); 83 | return false; 84 | } catch (_e) { 85 | return true; 86 | } 87 | } 88 | -------------------------------------------------------------------------------- /test/auto-checkout-remote-partial-branches.spec.ts: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env ts-node-dev 2 | 3 | /* eslint-disable @typescript-eslint/camelcase */ 4 | /* eslint-disable @typescript-eslint/explicit-function-return-type */ 5 | 6 | import assert from "assert"; 7 | 8 | import { BranchWhoNeedsLocalCheckout, decodeLineToCmd, encodeCmdToLine, gitStackedRebase } from "../git-stacked-rebase"; 9 | import { editor__internal } from "../internal"; 10 | import { nativeGetBranchNames } from "../native-git/branch"; 11 | import { modifyLines } from "../humanOp"; 12 | 13 | import { setupRemoteRepo } from "./util/setupRemoteRepo"; 14 | 15 | export default async function run() { 16 | await auto_checks_out_remote_partial_branches(); 17 | await give_chosen_name_to_local_branch(); 18 | } 19 | 20 | async function auto_checks_out_remote_partial_branches() { 21 | const { RemoteAlice, LocalBob } = await setupRemoteRepo(); 22 | 23 | /** 24 | * switch to latest branch to perform stacked rebase 25 | */ 26 | LocalBob.execSyncInRepo(`git checkout ${RemoteAlice.latestStackedBranchName}`); 27 | 28 | const remotePartialBranchesInAlice: string[] = RemoteAlice.partialBranches.map((b) => b.shorthand()); 29 | const localPartialBranchesInBobBefore: string[] = findPartialBranchesThatArePresentLocally(); 30 | 31 | function findPartialBranchesThatArePresentLocally( 32 | localBranches: string[] = nativeGetBranchNames(LocalBob.repo.workdir())("local") 33 | ) { 34 | return remotePartialBranchesInAlice.filter((partial) => localBranches.includes(partial)); 35 | } 36 | 37 | assert.deepStrictEqual( 38 | localPartialBranchesInBobBefore.length, 39 | 0, 40 | "expected partial branches to __not be__ checked out locally, to be able to test later that they will be." 41 | ); 42 | 43 | await gitStackedRebase({ 44 | initialBranch: RemoteAlice.initialBranch, 45 | gitDir: LocalBob.repo.workdir(), 46 | [editor__internal]: () => void 0 /** no edit */, 47 | }); 48 | 49 | const localPartialBranchesInBobAfter: string[] = findPartialBranchesThatArePresentLocally(); 50 | 51 | console.log({ 52 | remotePartialBranchesInAlice, 53 | localPartialBranchesInBobBefore, 54 | localPartialBranchesInBobAfter, 55 | }); 56 | 57 | assert.deepStrictEqual( 58 | localPartialBranchesInBobAfter.length, 59 | remotePartialBranchesInAlice.length, 60 | "expected partial branches to __be__ checked out locally by git-stacked-rebase." 61 | ); 62 | } 63 | 64 | async function give_chosen_name_to_local_branch() { 65 | const { RemoteAlice, LocalBob } = await setupRemoteRepo(); 66 | 67 | /** 68 | * switch to latest branch to perform stacked rebase 69 | */ 70 | LocalBob.execSyncInRepo(`git checkout ${RemoteAlice.latestStackedBranchName}`); 71 | 72 | const renamedLocalBranch = "partial-renamed-local-branch-hehe" as const; 73 | 74 | const isPartial = (b: string): boolean => b.includes("partial") 75 | 76 | assert(isPartial(renamedLocalBranch)) 77 | 78 | // TODO TS 79 | // @ts-ignore 80 | const remotePartialBranchesInAlice: string[] = findPartialBranches(RemoteAlice); 81 | const localPartialBranchesInBobBefore: string[] = findPartialBranches(LocalBob) 82 | 83 | // TODO CLEANUP PREV TEST TOO 84 | function findPartialBranches(owner: typeof RemoteAlice | typeof LocalBob, workdir = owner.repo.workdir()): string[] { 85 | return nativeGetBranchNames(workdir)("local").filter(isPartial) 86 | } 87 | 88 | assert.deepStrictEqual( 89 | localPartialBranchesInBobBefore.length, 90 | 0, 91 | "expected partial branches to __not be__ checked out locally, to be able to test later that they will be." 92 | ); 93 | 94 | await gitStackedRebase({ 95 | initialBranch: RemoteAlice.initialBranch, 96 | gitDir: LocalBob.repo.workdir(), 97 | [editor__internal]: ({filePath}) => { 98 | const branchNameOf2ndBranch: string = RemoteAlice.newPartialBranches[1][0]; 99 | modifyLines(filePath, (lines) => { 100 | const lineIdx: number = lines.findIndex(l => l.includes(branchNameOf2ndBranch))! 101 | const line: string = lines[lineIdx]; 102 | const cmd: BranchWhoNeedsLocalCheckout = decodeLineToCmd(line) 103 | console.log({ 104 | lineIdx, 105 | line, 106 | cmd 107 | }) 108 | const newCmd: BranchWhoNeedsLocalCheckout = { 109 | ...cmd, 110 | wantedLocalBranchName: renamedLocalBranch, 111 | } 112 | const newLine: string = encodeCmdToLine(newCmd) 113 | const newLines: string[] = lines.map((oldLine, i) => i === lineIdx ? newLine : oldLine) 114 | return newLines; 115 | }) 116 | }, 117 | }); 118 | 119 | const localPartialBranchesInBobAfter: string[] = findPartialBranches(LocalBob) 120 | 121 | console.log({ 122 | remotePartialBranchesInAlice, 123 | localPartialBranchesInBobBefore, 124 | localPartialBranchesInBobAfter, 125 | }); 126 | 127 | assert.deepStrictEqual( 128 | localPartialBranchesInBobAfter.length, 129 | remotePartialBranchesInAlice.length, 130 | "expected partial branches to __be__ checked out locally by git-stacked-rebase." 131 | ); 132 | }; 133 | 134 | if (!module.parent) { 135 | run(); 136 | } 137 | -------------------------------------------------------------------------------- /test/experiment.spec.ts: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env ts-node-dev 2 | 3 | /* eslint-disable @typescript-eslint/camelcase */ 4 | 5 | import fs from "fs"; 6 | 7 | import { setupRepo } from "./util/setupRepo"; 8 | 9 | import { defaultGitCmd } from "../options"; 10 | import { gitStackedRebase } from "../git-stacked-rebase"; 11 | import { humanOpChangeCommandOfNthCommitInto } from "../humanOp"; 12 | import { editor__internal } from "../internal"; 13 | 14 | export async function testCase(): Promise { 15 | const { 16 | common, 17 | commitsInLatest, 18 | read, 19 | execSyncInRepo, 20 | } = await setupRepo(); 21 | 22 | /** 23 | * 24 | */ 25 | console.log("launching 2nd rebase to change command of nth commit"); 26 | read(); 27 | 28 | const nthCommit2ndRebase = 5; 29 | 30 | await gitStackedRebase({ 31 | ...common, 32 | [editor__internal]: async ({ filePath }) => { 33 | humanOpChangeCommandOfNthCommitInto("edit", { 34 | filePath, // 35 | commitSHA: commitsInLatest[nthCommit2ndRebase], 36 | }); 37 | }, 38 | }); 39 | /** 40 | * rebase will now exit because of the "edit" command, 41 | * and so will our stacked rebase, 42 | * allowing us to edit. 43 | */ 44 | 45 | fs.writeFileSync(nthCommit2ndRebase.toString(), "new data from 2nd rebase\n"); 46 | 47 | execSyncInRepo(`${defaultGitCmd} add .`); 48 | execSyncInRepo(`${defaultGitCmd} -c commit.gpgSign=false commit --amend --no-edit`); 49 | 50 | execSyncInRepo(`${defaultGitCmd} rebase --continue`); 51 | 52 | execSyncInRepo(`${defaultGitCmd} status`); 53 | read(); 54 | 55 | /** 56 | * now some partial branches will be "gone" from the POV of the latestBranch<->initialBranch. 57 | * 58 | * TODO verify they are gone (history got modified successfully) 59 | */ 60 | 61 | // TODO 62 | 63 | /** 64 | * TODO continue with --apply 65 | * TODO and then verify that partial branches are "back" in our POV properly. 66 | */ 67 | 68 | console.log("attempting early 3rd rebase to --apply"); 69 | read(); 70 | 71 | await gitStackedRebase({ 72 | ...common, 73 | apply: true, 74 | }); 75 | } 76 | -------------------------------------------------------------------------------- /test/non-first-rebase-has-initial-branch-cached.spec.ts: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env ts-node-dev 2 | 3 | import fs from "fs"; 4 | import path from "path"; 5 | import assert from "assert"; 6 | 7 | import { gitStackedRebase } from "../git-stacked-rebase"; 8 | 9 | import { setupRepo } from "./util/setupRepo"; 10 | import { isMarkedThatNeedsToApply } from "../apply"; 11 | import { filenames } from "../filenames"; 12 | import { askQuestion__internal, editor__internal, noEditor } from "../internal"; 13 | import { humanOpChangeCommandOfNthCommitInto } from "../humanOp"; 14 | import { Questions, question } from "../util/createQuestion"; 15 | 16 | export async function nonFirstRebaseHasInitialBranchCached_TC() { 17 | await scenario1(); 18 | } 19 | 20 | async function scenario1() { 21 | const { common, repo, commitsInLatest } = await setupRepo(); 22 | 23 | const initialBranch = "master" as const; 24 | 25 | await gitStackedRebase({ 26 | ...common, 27 | initialBranch, 28 | apply: false, 29 | autoApplyIfNeeded: false, 30 | [editor__internal]: ({ filePath }) => { 31 | /** 32 | * force an apply to be needed, so that a second rebase is meaningful 33 | */ 34 | humanOpChangeCommandOfNthCommitInto("drop", { 35 | filePath, // 36 | commitSHA: commitsInLatest[2], 37 | }); 38 | }, 39 | }); 40 | 41 | // BEGIN COPY_PASTE 42 | // TODO take from `gitStackedRebase`: 43 | const dotGitDirPath: string = repo.path(); 44 | const pathToStackedRebaseDirInsideDotGit: string = path.join(dotGitDirPath, "stacked-rebase"); 45 | assert.deepStrictEqual( 46 | isMarkedThatNeedsToApply(pathToStackedRebaseDirInsideDotGit), // 47 | true, 48 | `expected a "needs-to-apply" mark to be set.` 49 | ); 50 | // END COPY_PASTE 51 | 52 | const pathToCache: string = path.join(pathToStackedRebaseDirInsideDotGit, filenames.initialBranch); 53 | const isCached: boolean = fs.existsSync(pathToCache); 54 | assert.deepStrictEqual(isCached, true, `expected initial branch to be cached after 1st run.`); 55 | 56 | const cachedValue: string = fs.readFileSync(pathToCache, { encoding: "utf-8" }); 57 | assert.deepStrictEqual( 58 | cachedValue, 59 | initialBranch, 60 | `expected the correct value to be cached ("${initialBranch}"), but found "${cachedValue}".` 61 | ); 62 | 63 | console.log("performing 2nd rebase, expecting it to re-use the cached value of the initialBranch successfully."); 64 | 65 | await gitStackedRebase({ 66 | ...common, 67 | /** 68 | * force unset initial branch 69 | */ 70 | initialBranch: undefined, 71 | ...noEditor, 72 | [askQuestion__internal]: (q, ...rest) => { 73 | if (q === Questions.need_to_apply_before_continuing) return "y"; 74 | return question(q, ...rest); 75 | }, 76 | }); 77 | } 78 | 79 | if (!module.parent) { 80 | nonFirstRebaseHasInitialBranchCached_TC(); 81 | } 82 | -------------------------------------------------------------------------------- /test/parse-argv-resolve-options.spec.ts: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env ts-node-dev 2 | 3 | import fs from "fs"; 4 | import path from "path"; 5 | import os from "os"; 6 | import assert from "assert"; 7 | 8 | import Git from "nodegit"; 9 | 10 | import { 11 | ResolvedGitStackedRebaseOptions, 12 | getDefaultResolvedOptions, 13 | parseArgs, 14 | parseArgv, 15 | resolveOptions, 16 | } from "../git-stacked-rebase"; 17 | 18 | import { rmdirSyncR } from "../util/fs"; 19 | import { log } from "../util/log"; 20 | 21 | export async function parseArgvResolveOptions_TC() { 22 | for (const testData of simpleTests) { 23 | log({ testData }); 24 | await runSimpleTest(...testData); 25 | } 26 | } 27 | 28 | type SimpleTestInput = [ 29 | specifiedOptions: Parameters[0], 30 | expectedOptions: Partial 31 | ]; 32 | 33 | /** 34 | * TODO: 35 | * - [ ] custom setup, i.e. a function w/ context that's run before parsing the options, to e.g. modify the config 36 | * - [ ] a way to handle Termination's, throw's in general 37 | * - [ ] multiple rebases one after another, to e.g. test that initialBranch is not needed for 2nd invocation 38 | * 39 | * prolly better to have separate file for more advanced tests, & keep this one simple 40 | */ 41 | const simpleTests: SimpleTestInput[] = [ 42 | /** ensure defaults produce the same defaults: */ 43 | [["origin/master"], {}], 44 | ["origin/master", {}], 45 | 46 | ["custom-branch", { initialBranch: "custom-branch" }], 47 | 48 | ["origin/master -a", { apply: true }], 49 | ["origin/master --apply", { apply: true }], 50 | 51 | ["origin/master -p -f", { push: true, forcePush: true }], 52 | ["origin/master --push --force", { push: true, forcePush: true }], 53 | 54 | ["origin/master --continue", { continue: true }], 55 | 56 | ["origin/master", { autoSquash: false }], 57 | ["origin/master --autosquash", { autoSquash: true }], 58 | ["origin/master --autosquash --no-autosquash", { autoSquash: false }], 59 | ["origin/master --autosquash --no-autosquash --autosquash", { autoSquash: true }], 60 | ["origin/master --autosquash --no-autosquash --autosquash --no-autosquash", { autoSquash: false }], 61 | 62 | ["origin/master -s -x ls", { branchSequencer: true, branchSequencerExec: "ls" }], 63 | ["origin/master --bs -x ls", { branchSequencer: true, branchSequencerExec: "ls" }], 64 | [ 65 | /** TODO E2E: test if paths to custom scripts work & in general if works as expected */ 66 | "origin/master --branch-sequencer --exec ./custom-script.sh", 67 | { branchSequencer: true, branchSequencerExec: "./custom-script.sh" }, 68 | ], 69 | ]; 70 | 71 | async function runSimpleTest(specifiedOptions: SimpleTestInput[0], expectedOptions: SimpleTestInput[1]) { 72 | const tmpdir = fs.mkdtempSync(path.join(os.tmpdir(), "argv-options-test")); 73 | const tmpfile = path.join(tmpdir, ".git-config"); 74 | const tmpGitConfig: Git.Config = await Git.Config.openOndisk(tmpfile); 75 | 76 | const parsedArgv = typeof specifiedOptions === "string" ? parseArgs(specifiedOptions) : parseArgv(specifiedOptions); 77 | log({ parsedArgv }); 78 | 79 | const resolvedOptions: ResolvedGitStackedRebaseOptions = await resolveOptions(parsedArgv, { 80 | config: tmpGitConfig, // 81 | dotGitDirPath: path.join(tmpdir, ".git"), 82 | }); 83 | 84 | const fullExpectedOptions: ResolvedGitStackedRebaseOptions = { ...getDefaultResolvedOptions(), ...expectedOptions }; 85 | assert.deepStrictEqual(resolvedOptions, fullExpectedOptions); 86 | 87 | // cleanup 88 | rmdirSyncR(tmpdir); 89 | } 90 | 91 | if (!module.parent) { 92 | parseArgvResolveOptions_TC(); 93 | } 94 | -------------------------------------------------------------------------------- /test/parseRangeDiff.spec.ts: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env ts-node-dev 2 | 3 | import assert from "assert"; 4 | 5 | import { log } from "../util/log"; 6 | import { RangeDiffBase, parseRangeDiff } from "../ref-finder"; 7 | 8 | export async function parseRangeDiff_TC() { 9 | for (const testData of simpleTests) { 10 | log({ testData }); 11 | 12 | const [lines, expectedOutput] = testData; 13 | 14 | const output = parseRangeDiff(lines.trim().split("\n")); 15 | assert.deepStrictEqual(output, expectedOutput); 16 | } 17 | } 18 | 19 | type SimpleTestInput = [lines: string, rangeDiff: RangeDiffBase[]]; 20 | 21 | const simpleTests: SimpleTestInput[] = [ 22 | // https://git-scm.com/docs/git-range-diff#_examples 23 | [ 24 | ` 25 | -: ------- > 1: 0ddba11 Prepare for the inevitable! 26 | 1: c0debee = 2: cab005e Add a helpful message at the start 27 | 2: f00dbal ! 3: decafe1 Describe a bug 28 | @@ -1,3 +1,3 @@ 29 | Author: A U Thor 30 | 31 | -TODO: Describe a bug 32 | +Describe a bug 33 | @@ -324,5 +324,6 34 | This is expected. 35 | 36 | -+What is unexpected is that it will also crash. 37 | ++Unexpectedly, it also crashes. This is a bug, and the jury is 38 | ++still out there how to fix it best. See ticket #314 for details. 39 | 40 | Contact 41 | 3: bedead < -: ------- TO-UNDO 42 | `, 43 | [ 44 | { 45 | diff_lines: [], 46 | eq_sign: ">", 47 | msg: "Prepare for the inevitable!", 48 | nth_after: "1", 49 | nth_before: "-", 50 | sha_after: "-------", 51 | sha_before: "0ddba11", 52 | }, 53 | { 54 | diff_lines: [], 55 | eq_sign: "=", 56 | msg: "Add a helpful message at the start", 57 | nth_after: "2", 58 | nth_before: "1", 59 | sha_after: "c0debee", 60 | sha_before: "cab005e", 61 | }, 62 | { 63 | diff_lines: [ 64 | " @@ -1,3 +1,3 @@", 65 | " Author: A U Thor ", 66 | "", 67 | " -TODO: Describe a bug", 68 | " +Describe a bug", 69 | " @@ -324,5 +324,6", 70 | " This is expected.", 71 | "", 72 | " -+What is unexpected is that it will also crash.", 73 | " ++Unexpectedly, it also crashes. This is a bug, and the jury is", 74 | " ++still out there how to fix it best. See ticket #314 for details.", 75 | "", 76 | " Contact", 77 | ], 78 | eq_sign: "!", 79 | msg: "Describe a bug", 80 | nth_after: "3", 81 | nth_before: "2", 82 | sha_after: "f00dbal", 83 | sha_before: "decafe1", 84 | }, 85 | { 86 | diff_lines: [], 87 | eq_sign: "<", 88 | msg: "TO-UNDO", 89 | nth_after: "-", 90 | nth_before: "3", 91 | sha_after: "bedead", 92 | sha_before: "-------", 93 | }, 94 | ], 95 | ], 96 | 97 | // 98 | ]; 99 | 100 | if (!module.parent) { 101 | parseRangeDiff_TC(); 102 | } 103 | -------------------------------------------------------------------------------- /test/run.ts: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env ts-node-dev 2 | 3 | import { testCase } from "./experiment.spec"; 4 | import reducePathTC from "../git-reconcile-rewritten-list/reducePath.spec"; 5 | import { parseNewGoodCommandsSpec } from "../parse-todo-of-stacked-rebase/parseNewGoodCommands.spec"; 6 | import autoCheckoutRemotePartialBranchesTC from "./auto-checkout-remote-partial-branches.spec"; 7 | import { applyTC } from "./apply.spec"; 8 | import { argparse_TC } from "../argparse/argparse.spec"; 9 | import { parseArgvResolveOptions_TC } from "./parse-argv-resolve-options.spec"; 10 | import { nonFirstRebaseHasInitialBranchCached_TC } from "./non-first-rebase-has-initial-branch-cached.spec"; 11 | import { parseRangeDiff_TC } from "./parseRangeDiff.spec"; 12 | 13 | import { sequentialResolve } from "../util/sequentialResolve"; 14 | import { cleanupTmpRepos } from "./util/tmpdir"; 15 | 16 | main(); 17 | function main() { 18 | process.on("uncaughtException", (e) => { 19 | printErrorAndExit(e); 20 | }); 21 | 22 | process.on("unhandledRejection", (e) => { 23 | printErrorAndExit(e); 24 | }); 25 | 26 | // TODO Promise.all 27 | sequentialResolve([ 28 | testCase, // 29 | async () => reducePathTC(), 30 | parseNewGoodCommandsSpec, 31 | autoCheckoutRemotePartialBranchesTC, 32 | applyTC, 33 | async () => argparse_TC(), 34 | parseArgvResolveOptions_TC, 35 | nonFirstRebaseHasInitialBranchCached_TC, 36 | async () => parseRangeDiff_TC(), 37 | ]) 38 | .then(cleanupTmpRepos) 39 | .then(() => { 40 | process.stdout.write("\nsuccess\n\n"); 41 | process.exit(0); 42 | }) 43 | .catch(printErrorAndExit); 44 | } 45 | 46 | function printErrorAndExit(e: unknown) { 47 | console.error(e); 48 | 49 | console.log("\nfull trace:"); 50 | console.trace(e); 51 | 52 | process.stdout.write("\nfailure\n\n"); 53 | process.exit(1); 54 | } 55 | -------------------------------------------------------------------------------- /test/util/setupRemoteRepo.ts: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env ts-node-dev 2 | 3 | import Git from "nodegit"; 4 | 5 | import { setupRepo, setupRepoBase } from "./setupRepo"; 6 | 7 | import { log } from "../../util/log"; 8 | 9 | // eslint-disable-next-line @typescript-eslint/explicit-function-return-type 10 | export async function setupRemoteRepo() { 11 | const { 12 | baseRepoInRemote, // 13 | baseRepoInLocalAlice, 14 | baseRepoInLocalBob, 15 | } = await setupRemoteRepoBase(); 16 | 17 | const RemoteBareServer = await Promise.resolve(baseRepoInRemote); 18 | 19 | const RemoteAlice = await Promise.resolve(baseRepoInLocalAlice).then((owner) => 20 | /** 21 | * setup stacked branches 22 | */ 23 | setupRepo({ 24 | initRepoBase: () => baseRepoInLocalAlice, 25 | }).then((partials) => ({ 26 | ...partials, 27 | ...owner, 28 | })) 29 | ); 30 | 31 | const LocalBob = await Promise.resolve(baseRepoInLocalBob) 32 | .then((owner) => (Git.Remote.addFetch(owner.repo, "origin", "+refs/remotes/origin/*:refs/heads/*"), owner)) 33 | .then((owner) => ({ 34 | ...owner, 35 | /** 36 | * TODO NATIVE? 37 | */ 38 | async fetch(remote = "origin"): Promise { 39 | return await owner.repo.fetch(remote); 40 | }, 41 | })); 42 | 43 | log({ RemoteBareServer, RemoteAlice, LocalBob }); 44 | 45 | RemoteAlice.push(); 46 | await LocalBob.fetch(); 47 | 48 | /** 49 | * need to checkout the initial branch first, 50 | * so that repo is in valid state, 51 | * instead of at 0-commit master. 52 | */ 53 | LocalBob.execSyncInRepo(`git checkout ${RemoteAlice.initialBranch}`); 54 | 55 | return { 56 | RemoteBareServer, 57 | RemoteAlice, 58 | LocalBob, 59 | }; 60 | } 61 | 62 | /** 63 | * https://git-scm.com/book/en/v2/Git-on-the-Server-The-Protocols 64 | * https://git-scm.com/book/en/v2/Git-on-the-Server-Getting-Git-on-a-Server 65 | * 66 | * 67 | * idea is to setup a remote repo, 68 | * as a bare repository. 69 | * 70 | * then clone it in some location, 71 | * imitating an external user who's done something, 72 | * and then having a fresh, not-yet-fetched, 73 | * not-yet-all-branches-checked-out, etc. repo. 74 | * 75 | * TODO maybe rename to "setupRepoWithRemoteChanges" 76 | * 77 | */ 78 | // eslint-disable-next-line @typescript-eslint/explicit-function-return-type 79 | export async function setupRemoteRepoBase() { 80 | const baseRepoInRemote = await setupRepoBase({ 81 | bare: 1, // 82 | infoPrefix: "Remote", 83 | }); 84 | 85 | // const someoneElse = {}; // TODO w/ sig, etc 86 | 87 | const remotePath: string = "file://" + baseRepoInRemote.dir; 88 | 89 | const baseRepoInLocalAlice = await setupRepoBase({ 90 | infoPrefix: "Alice", 91 | initRepo: ({ dir }) => Git.Clone.clone(remotePath, dir, {}), 92 | }); 93 | 94 | const baseRepoInLocalBob = await setupRepoBase({ 95 | infoPrefix: "Bob", 96 | initRepo: ({ dir }) => Git.Clone.clone(remotePath, dir, {}), 97 | }); 98 | 99 | return { 100 | baseRepoInRemote, 101 | baseRepoInLocalAlice, 102 | baseRepoInLocalBob, 103 | }; 104 | } 105 | 106 | if (!module.parent) { 107 | setupRemoteRepo(); 108 | } 109 | -------------------------------------------------------------------------------- /test/util/setupRepo.ts: -------------------------------------------------------------------------------- 1 | /* eslint-disable @typescript-eslint/camelcase */ 2 | 3 | import fs from "fs"; 4 | import path from "path"; 5 | import assert from "assert"; 6 | 7 | import Git from "nodegit"; 8 | 9 | import { gitStackedRebase } from "../../git-stacked-rebase"; 10 | import { defaultGitCmd } from "../../options"; 11 | import { configKeys } from "../../config"; 12 | import { humanOpAppendLineAfterNthCommit } from "../../humanOp"; 13 | import { editor__internal, getGitConfig__internal } from "../../internal"; 14 | import { getBranches, nativeGetBranchNames, nativePush } from "../../native-git/branch"; 15 | 16 | import { createExecSyncInRepo } from "../../util/execSyncInRepo"; 17 | import { UnpromiseFn } from "../../util/Unpromise"; 18 | import { log } from "../../util/log"; 19 | 20 | import { createTmpdir, CreateTmpdirOpts } from "./tmpdir"; 21 | 22 | type Opts = { 23 | blockWithRead?: boolean; 24 | commitCount?: number; 25 | 26 | /** 27 | * 28 | */ 29 | initRepoBase?: typeof setupRepoBase | UnpromiseFn; 30 | } & Omit; 31 | 32 | // eslint-disable-next-line @typescript-eslint/explicit-function-return-type 33 | export async function setupRepo({ 34 | blockWithRead = false, // 35 | commitCount = 12, 36 | initRepoBase, 37 | ...rest 38 | }: Opts = {}) { 39 | const ctx: SetupRepoOpts = { ...rest, bare: 0 }; 40 | 41 | const { common: _common, ...base } = await (initRepoBase?.(ctx) ?? setupRepoBase(ctx)); 42 | 43 | /** 44 | * create a single initial commit, 45 | * so that other git ops work as expected. 46 | */ 47 | await createInitialCommit(); 48 | 49 | async function createInitialCommit() { 50 | const initialCommitId = "Initial-commit-from-setupRepo"; 51 | const initialCommitFilePath = path.join(base.dir, initialCommitId); 52 | const relFilepaths = [initialCommitId]; 53 | 54 | fs.writeFileSync(initialCommitFilePath, initialCommitId); 55 | const initialCommit: Git.Oid = await base.repo.createCommitOnHead( 56 | relFilepaths, // 57 | base.sig, 58 | base.sig, 59 | initialCommitId 60 | ); 61 | 62 | log("initial commit %s", initialCommit.tostrS()); 63 | } 64 | 65 | const commitOidsInInitial: Git.Oid[] = []; 66 | await appendCommitsTo( 67 | commitOidsInInitial, // 68 | 3, 69 | base.repo, 70 | base.sig, 71 | base.dir 72 | ); 73 | 74 | const initialBranchRef: Git.Reference = await base.repo.getCurrentBranch(); 75 | 76 | const initialBranch: string = initialBranchRef.shorthand(); 77 | 78 | const common = { 79 | ..._common, 80 | initialBranch, 81 | } as const; 82 | 83 | const commitsInInitial: string[] = commitOidsInInitial.map((oid) => oid.tostrS()); 84 | 85 | const latestStackedBranchName = "stack-latest"; 86 | const headCommit: Git.Commit = await base.repo.getHeadCommit(); 87 | const createBranchCmd = `${defaultGitCmd} checkout -b ${latestStackedBranchName} ${headCommit}`; 88 | base.execSyncInRepo(createBranchCmd); 89 | 90 | const read = (): void => (blockWithRead ? void base.execSyncInRepo("read") : void 0); 91 | 92 | read(); 93 | 94 | const commitOidsInLatestStacked: Git.Oid[] = []; 95 | await appendCommitsTo(commitOidsInLatestStacked, commitCount, base.repo, base.sig, base.dir); 96 | 97 | const commitsInLatest: string[] = commitOidsInLatestStacked.map((oid) => oid.tostrS()); 98 | 99 | const newPartialBranches = [ 100 | ["partial-1", 4], 101 | ["partial-2", 6], 102 | ["partial-3", 8], 103 | ] as const; 104 | 105 | log("launching 0th rebase to create partial branches"); 106 | await gitStackedRebase({ 107 | ...common, 108 | [editor__internal]: ({ filePath }) => { 109 | log("filePath %s", filePath); 110 | 111 | for (const [newPartial, nthCommit] of newPartialBranches) { 112 | humanOpAppendLineAfterNthCommit(`branch-end-new ${newPartial}`, { 113 | filePath, 114 | commitSHA: commitOidsInLatestStacked[nthCommit].tostrS(), 115 | }); 116 | } 117 | 118 | log("finished editor"); 119 | 120 | read(); 121 | }, 122 | }); 123 | 124 | read(); 125 | const partialBranches: Git.Reference[] = []; 126 | for (const [newPartial] of newPartialBranches) { 127 | /** 128 | * will throw if branch does not exist 129 | * TODO "properly" expect to not throw 130 | */ 131 | const branch = await Git.Branch.lookup(base.repo, newPartial, Git.Branch.BRANCH.LOCAL); 132 | partialBranches.push(branch); 133 | } 134 | 135 | return { 136 | ...base, 137 | common, 138 | read, // TODO move to base 139 | 140 | initialBranchRef, 141 | initialBranch, 142 | commitOidsInInitial, 143 | commitsInInitial, 144 | 145 | latestStackedBranchName, 146 | commitOidsInLatestStacked, 147 | commitsInLatest, 148 | 149 | partialBranches, 150 | newPartialBranches, 151 | } as const; 152 | } 153 | 154 | export type InitRepoCtx = { 155 | Git: typeof Git; 156 | dir: string; 157 | bare: number; 158 | }; 159 | 160 | export type SetupRepoOpts = Partial & { 161 | initRepo?: (ctx: InitRepoCtx) => Promise; 162 | }; 163 | 164 | export type RepoBase = ReturnType; 165 | 166 | // eslint-disable-next-line @typescript-eslint/explicit-function-return-type 167 | export async function setupRepoBase({ 168 | random = true, // 169 | bare = 0, 170 | infoPrefix = "", 171 | initRepo = (ctx) => ctx.Git.Repository.init(ctx.dir, ctx.bare), 172 | }: SetupRepoOpts = {}) { 173 | const dir: string = createTmpdir({ random, bare, infoPrefix }); 174 | 175 | /** 176 | * TODO make concurrent-safe (lol) 177 | */ 178 | process.chdir(dir); 179 | log("chdir to tmpdir %s", dir); 180 | 181 | const repo: Git.Repository = await initRepo({ Git, dir, bare }); 182 | 183 | const config: Git.Config = await repo.config(); 184 | 185 | await config.setBool(configKeys.autoApplyIfNeeded, Git.Config.MAP.FALSE); 186 | await config.setString("user.email", "tester@test.com"); 187 | await config.setString("user.name", "tester"); 188 | 189 | /** 190 | * gpg signing in tests not possible i believe, 191 | * at least wasn't working. 192 | */ 193 | await config.setBool(configKeys.gpgSign, Git.Config.MAP.FALSE); 194 | 195 | const sig: Git.Signature = await Git.Signature.default(repo); 196 | log("sig %s", sig); 197 | 198 | const execSyncInRepo = createExecSyncInRepo(repo.workdir()); 199 | 200 | /** 201 | * common options to GitStackedRebase, 202 | * esp. for running tests 203 | * (consumes the provided config, etc) 204 | */ 205 | const common = { 206 | gitDir: dir, 207 | [getGitConfig__internal]: () => config, 208 | } as const; // satisfies SomeOptionsForGitStackedRebase; // TODO 209 | 210 | const getBranchNames = nativeGetBranchNames(repo.workdir()); 211 | 212 | return { 213 | dir, 214 | repo, 215 | config, 216 | sig, 217 | execSyncInRepo, 218 | common, 219 | // 220 | getBranchNames, 221 | getBranches: getBranches(repo), 222 | push: nativePush(repo.workdir()), 223 | } as const; 224 | } 225 | 226 | export async function appendCommitsTo( 227 | alreadyExistingCommits: Git.Oid[], 228 | n: number, 229 | repo: Git.Repository, // 230 | sig: Git.Signature, 231 | dir: string 232 | ): Promise { 233 | assert(n > 0, "cannot append <= 0 commits"); 234 | 235 | const commits: string[] = new Array(n) 236 | .fill(0) // 237 | .map((_, i) => "a".charCodeAt(0) + i + alreadyExistingCommits.length) 238 | .map((ascii) => String.fromCharCode(ascii)); 239 | 240 | for (const commit of commits) { 241 | const branchName: string = repo.isEmpty() ? "" : (await repo.getCurrentBranch()).shorthand(); 242 | const commitTitle: string = commit + " in " + branchName; 243 | 244 | const commitFilePath: string = path.join(dir, commit); 245 | const relFilepaths = [commit]; 246 | 247 | fs.writeFileSync(commitFilePath, commitTitle); 248 | const oid: Git.Oid = await repo.createCommitOnHead(relFilepaths, sig, sig, commitTitle); 249 | 250 | alreadyExistingCommits.push(oid); 251 | 252 | log(`oid of commit "%s" in branch "%s": %s`, commit, branchName, oid); 253 | } 254 | 255 | return await repo.getCurrentBranch(); 256 | } 257 | -------------------------------------------------------------------------------- /test/util/tmpdir.ts: -------------------------------------------------------------------------------- 1 | import fs from "fs"; 2 | import path from "path"; 3 | 4 | export type CreateTmpdirOpts = { 5 | random?: boolean; 6 | bare?: number; 7 | infoPrefix?: string; 8 | }; 9 | 10 | export function createTmpdir({ 11 | random = true, // 12 | bare = 0, 13 | infoPrefix = "", 14 | }: CreateTmpdirOpts = {}): string { 15 | let dir: string; 16 | 17 | const prefix: string = 18 | ".tmp-" + // 19 | (bare ? "bare-" : "") + 20 | (infoPrefix.trim() ? infoPrefix + "-" : ""); 21 | 22 | if (random) { 23 | dir = fs.mkdtempSync(path.join(__dirname, prefix), { encoding: "utf-8" }); 24 | addRepoForCleanup(dir); 25 | return dir; 26 | } 27 | 28 | dir = path.join(__dirname, prefix); 29 | /** 30 | * do NOT add for cleanup, 31 | * because it's not random 32 | */ 33 | 34 | if (fs.existsSync(dir)) { 35 | fs.rmdirSync(dir, { recursive: true, ...{ force: true } }); 36 | } 37 | fs.mkdirSync(dir); 38 | 39 | return dir; 40 | } 41 | 42 | export const foldersToDeletePath: string = path.join(__dirname, "folders-to-delete"); 43 | 44 | export function addRepoForCleanup(dir: string): void { 45 | if (!fs.existsSync(foldersToDeletePath)) { 46 | fs.writeFileSync(foldersToDeletePath, ""); 47 | } 48 | 49 | fs.appendFileSync(foldersToDeletePath, dir + "\n", { encoding: "utf-8" }); 50 | } 51 | 52 | export function cleanupTmpRepos(): void { 53 | const deletables: string[] = fs.readFileSync(foldersToDeletePath, { encoding: "utf-8" }).split("\n"); 54 | 55 | for (const d of deletables) { 56 | if (fs.existsSync(d)) { 57 | fs.rmdirSync(d, { recursive: true, ...{ force: true } }); 58 | } 59 | } 60 | } 61 | -------------------------------------------------------------------------------- /tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "compilerOptions": { 3 | "target": "es2017", 4 | "module": "commonjs", 5 | "lib": [ 6 | // 7 | "dom", 8 | "es6", 9 | "es2017", 10 | "esnext.asynciterable" 11 | ], 12 | "declaration": true, 13 | "skipLibCheck": false, 14 | "sourceMap": true, 15 | "outDir": "./dist", 16 | "moduleResolution": "node", 17 | "removeComments": true, 18 | "noImplicitAny": true, 19 | "strictNullChecks": true, 20 | "strictFunctionTypes": true, 21 | "noImplicitThis": true, 22 | "noUnusedLocals": true, 23 | "noUnusedParameters": true, 24 | "noImplicitReturns": true, 25 | "noFallthroughCasesInSwitch": true, 26 | "allowSyntheticDefaultImports": true, 27 | "esModuleInterop": true, 28 | "emitDecoratorMetadata": true, 29 | "experimentalDecorators": true, 30 | "resolveJsonModule": true, 31 | "baseUrl": ".", 32 | "types": [ 33 | // 34 | "node" 35 | ] 36 | }, 37 | "exclude": [ 38 | "node_modules", // 39 | "dist", 40 | /** 41 | * ignore test, because of annoying and incorrect error: TS4058 42 | */ 43 | "test" 44 | ], 45 | "include": [ 46 | // 47 | "./src/**/*.ts", 48 | "./**/*.ts" 49 | ], 50 | "ts-node": { 51 | "files": true, 52 | "ignore": [ 53 | /** 54 | * same as ts-node's default 55 | */ 56 | "(?:^|/)node_modules/" // 57 | ] 58 | } 59 | // TODO: test project references 60 | } 61 | -------------------------------------------------------------------------------- /util/Unpromise.ts: -------------------------------------------------------------------------------- 1 | export type Unpromise = T extends Promise ? U : never; 2 | export type UnpromiseFn = T extends () => Promise ? () => U : never; 3 | -------------------------------------------------------------------------------- /util/assertNever.ts: -------------------------------------------------------------------------------- 1 | export function assertNever(_x: never): never { 2 | throw new Error(`assertNever called (with value ${_x}) - should've been disallowed at compile-time`); 3 | } 4 | -------------------------------------------------------------------------------- /util/createQuestion.ts: -------------------------------------------------------------------------------- 1 | import readline from "readline"; 2 | 3 | export const createQuestion = 4 | ( 5 | rl = readline.createInterface(process.stdin, process.stdout) // 6 | ) => 7 | ( 8 | q: string // 9 | ): Promise => 10 | new Promise((r) => 11 | rl.question(q, (ans) => { 12 | rl.close(); 13 | r(ans); 14 | }) 15 | ); 16 | 17 | export type AskQuestion = typeof question; 18 | 19 | export const question = ( 20 | q: typeof Questions[keyof typeof Questions] | (string & {}), // 21 | { 22 | prefix = "", // 23 | suffix = "", 24 | cb = (ans: string): string => ans, 25 | } = {} 26 | ): string | Promise => createQuestion()(prefix + q + suffix).then(cb); 27 | 28 | export const Questions = { 29 | need_to_apply_before_continuing: "need to --apply before continuing. proceed? [Y/n/(a)lways] ", // 30 | commit_has_multiple_branches_pointing_at_it__which_to_use_for_pr_stack: `Which branch to use for the PR stack? `, 31 | open_urls_in_web_browser: "Open URLs in default web browser? [Y/n/(a)lways] ", 32 | } as const; 33 | 34 | /** 35 | * --- 36 | */ 37 | 38 | export type AskWhichBranchEndToUseForStackedPRsCtx = { 39 | branchEnds: string[]; 40 | commitSha: string; 41 | askQuestion: AskQuestion; 42 | nonFirstAsk: boolean; 43 | }; 44 | export async function askWhichBranchEndToUseForStackedPRs({ 45 | branchEnds, // 46 | commitSha, 47 | askQuestion, 48 | nonFirstAsk, 49 | }: AskWhichBranchEndToUseForStackedPRsCtx) { 50 | const prefix: string = 51 | (nonFirstAsk ? "\n" : "") + 52 | `Commit: ${commitSha}` + 53 | `\nBranch:` + 54 | "\n" + 55 | branchEnds.map((branch, idx) => `[${idx + 1}] ${branch}`).join("\n") + 56 | `\n` + 57 | `\nAbove commit has multiple branches pointing at it.` + 58 | `\n`; 59 | 60 | const suffix: string = `Choose a number 1-${branchEnds.length}: `; 61 | 62 | let rawAnswer: string; 63 | let chosenBranchIdx: number; 64 | 65 | process.stdout.write(prefix); 66 | 67 | do { 68 | const ctx = { prefix: "", suffix }; 69 | rawAnswer = await askQuestion( 70 | Questions.commit_has_multiple_branches_pointing_at_it__which_to_use_for_pr_stack, 71 | ctx 72 | ); 73 | chosenBranchIdx = Number(rawAnswer) - 1; 74 | } while (!isGoodAnswer()); 75 | 76 | function isGoodAnswer(): boolean { 77 | if (!rawAnswer?.trim()) return false; 78 | if (Number.isNaN(chosenBranchIdx)) return false; 79 | if (chosenBranchIdx < 0) return false; 80 | if (chosenBranchIdx >= branchEnds.length) return false; 81 | 82 | return true; 83 | } 84 | 85 | const chosenBranch: string = branchEnds[chosenBranchIdx]; 86 | 87 | process.stdout.write(`${chosenBranch}\n`); 88 | return chosenBranch; 89 | } 90 | -------------------------------------------------------------------------------- /util/delay.ts: -------------------------------------------------------------------------------- 1 | export const delay = (ms: number): Promise => new Promise((r) => setTimeout(r, ms)); 2 | -------------------------------------------------------------------------------- /util/error.ts: -------------------------------------------------------------------------------- 1 | export class Termination extends Error { 2 | constructor(public message: string, public exitCode: number = 1) { 3 | super(message); 4 | } 5 | } 6 | -------------------------------------------------------------------------------- /util/execSyncInRepo.ts: -------------------------------------------------------------------------------- 1 | import { execSync } from "child_process"; 2 | 3 | import { pipestdio } from "pipestdio"; 4 | 5 | import { log } from "./log"; 6 | 7 | export type CreateExecSyncInRepoConfig = { 8 | logCmd?: boolean; 9 | }; 10 | 11 | /** 12 | * always use this when doing git commands, 13 | * because if user is in a different directory 14 | * & is running git-stacked-rebase w/ a different path, 15 | * then the git commands, without the repo.workdir() as cwd, 16 | * would act on the current directory that the user is in (their cwd), 17 | * as opposted to the actual target repo (would be very bad!) 18 | */ 19 | // eslint-disable-next-line @typescript-eslint/explicit-function-return-type 20 | export const createExecSyncInRepo = (repoPath: string, { logCmd = !!process.env.GSR_DEBUG }: CreateExecSyncInRepoConfig = {}) => ( 21 | command: string, 22 | extraOptions: Parameters[1] = {} 23 | ) => ( 24 | logCmd && log(`execSync: ${command}`), 25 | execSync(command, { 26 | ...pipestdio(), 27 | ...extraOptions, 28 | /** 29 | * the `cwd` must be the last param here 30 | * to avoid accidentally overwriting it. 31 | * TODO TS - enforce 32 | */ 33 | cwd: repoPath, 34 | }) 35 | ); 36 | -------------------------------------------------------------------------------- /util/fs.ts: -------------------------------------------------------------------------------- 1 | import fs from "fs"; 2 | 3 | export const isDirEmptySync = (dirPath: fs.PathLike): boolean => fs.readdirSync(dirPath).length === 0; 4 | 5 | /** node mantainers are a-holes for this breaking change */ 6 | export const rmdirSyncR = (dir: fs.PathLike) => fs.rmdirSync(dir, { recursive: true, ...{ force: true } }); 7 | -------------------------------------------------------------------------------- /util/lockableArray.ts: -------------------------------------------------------------------------------- 1 | const isLockedKey = Symbol("isLocked"); 2 | 3 | export type Unlocked = T[] & { [isLockedKey]: false }; 4 | export type Locked = T[] & { [isLockedKey]: true }; 5 | export type Lockable = Unlocked | Locked; 6 | 7 | const isLocked = (array: Lockable): boolean => array[isLockedKey]; 8 | 9 | /** 10 | * marks the array as locked. 11 | * 12 | * pushing is still allowed, but will no longer 13 | * add items to the array. 14 | * 15 | */ 16 | export const lock = (array: Lockable): Locked => 17 | Object.assign( 18 | array, // 19 | { [isLockedKey]: true } as const 20 | ); 21 | 22 | /** 23 | * import `lock` to lock the array. 24 | */ 25 | export const createLockableArray = (initialItems: T[] = []): Lockable => { 26 | const array: Unlocked = Object.assign( 27 | initialItems, // 28 | { [isLockedKey]: false } as const 29 | ); 30 | 31 | const push = (item: T): typeof array["length"] => { 32 | if (!isLocked(array)) { 33 | Array.prototype.push.call(array, item); 34 | } 35 | return array.length; 36 | }; 37 | 38 | array.push = push; 39 | 40 | return array; 41 | }; 42 | -------------------------------------------------------------------------------- /util/log.ts: -------------------------------------------------------------------------------- 1 | import fs from "fs"; 2 | import path from "path"; 3 | import util from "util"; 4 | 5 | import { tmpdirConst } from "./tmpdir"; 6 | 7 | export const GSR_LOGDIR: string = tmpdirConst("git-stacked-rebase", "logs"); 8 | 9 | const GSR_LAUNCH_TIMESTAMP: string = new Date().toISOString(); 10 | const GSR_CURRENT_LAUNCH_LOGFILE = path.join(GSR_LOGDIR, GSR_LAUNCH_TIMESTAMP + ".log"); 11 | 12 | export function log(...msgs: any[]): void { 13 | if (process.env.GSR_DEBUG || process.env.CI) { 14 | console.log(...msgs); 15 | } 16 | 17 | const out = util.format(...msgs) + "\n"; 18 | fs.appendFileSync(GSR_CURRENT_LAUNCH_LOGFILE, out, { encoding: "utf-8" }); 19 | } 20 | 21 | /** 22 | * sometimes want to always print more informative messages, 23 | * while also storing into logfile. 24 | */ 25 | export function logAlways(...msgs: any[]): void { 26 | console.log(...msgs); 27 | 28 | const out = util.format(...msgs) + "\n"; 29 | fs.appendFileSync(GSR_CURRENT_LAUNCH_LOGFILE, out, { encoding: "utf-8" }); 30 | } 31 | -------------------------------------------------------------------------------- /util/noop.ts: -------------------------------------------------------------------------------- 1 | /** 2 | * a simple function that does nothing (no-operation), 3 | * used to prevent compiler errors for unused code. 4 | * especially useful if you're experimenting temporarily. 5 | */ 6 | export function noop(..._xs: any[]): void { 7 | // 8 | } 9 | -------------------------------------------------------------------------------- /util/removeUndefinedProperties.ts: -------------------------------------------------------------------------------- 1 | export function removeUndefinedProperties>(object: U): T { 2 | for (const key in object) { 3 | if (object[key] === undefined) { 4 | delete object[key]; 5 | } 6 | } 7 | 8 | return object as unknown as T; 9 | /** 10 | * TODO TS - we're not doing what we're saying we're doing here. 11 | * 12 | * we're simply deleting undefined properties, 13 | * but in the type system, we're saying that "we are adding legit values to properties who were undefined", 14 | * which is obviously incorrect. 15 | */ 16 | } 17 | -------------------------------------------------------------------------------- /util/sequentialResolve.ts: -------------------------------------------------------------------------------- 1 | export const sequentialResolve = (xs: (() => Promise)[]): Promise => 2 | xs.reduce( 3 | (prev, curr) => prev.then(curr), // 4 | (Promise.resolve() as unknown) as Promise 5 | ); 6 | -------------------------------------------------------------------------------- /util/stdout.ts: -------------------------------------------------------------------------------- 1 | export const stdout = (msg: string) => process.stdout.write(msg); 2 | -------------------------------------------------------------------------------- /util/tmpdir.ts: -------------------------------------------------------------------------------- 1 | import fs from "fs"; 2 | import path from "path"; 3 | import os from "os"; 4 | 5 | export const tmpdirRand = (...prefixes: string[]): string => fs.mkdtempSync(path.join(os.tmpdir(), ...prefixes)); 6 | 7 | export const tmpdirConst = (...prefixes: string[]): string => { 8 | const dirpath = path.join(os.tmpdir(), ...prefixes); 9 | fs.mkdirSync(dirpath, { recursive: true, ...{ force: true } }); 10 | 11 | return dirpath; 12 | }; 13 | -------------------------------------------------------------------------------- /util/tuple.ts: -------------------------------------------------------------------------------- 1 | /** 2 | * ...because eslint >5 sucks 3 | */ 4 | 5 | export type Single = [A]; 6 | export type ReadonlySingle = readonly [A]; 7 | 8 | export type Tuple = [A, B]; 9 | export type ReadonlyTuple = readonly [A, B]; 10 | 11 | export type Triple = [A, B, C]; 12 | export type ReadonlyTriple = readonly [A, B, C]; 13 | -------------------------------------------------------------------------------- /util/uniq.ts: -------------------------------------------------------------------------------- 1 | export const uniq = (arr: T[]): T[] => [...new Set(arr)]; 2 | --------------------------------------------------------------------------------