diff --git a/git-stacked-rebase.ts b/git-stacked-rebase.ts index c23a7935..f68c8607 100755 --- a/git-stacked-rebase.ts +++ b/git-stacked-rebase.ts @@ -20,7 +20,15 @@ import { noop } from "./util/noop"; import { uniq } from "./util/uniq"; import { parseTodoOfStackedRebase } from "./parse-todo-of-stacked-rebase/parseTodoOfStackedRebase"; import { Termination } from "./util/error"; -import { GoodCommand, namesOfRebaseCommandsThatMakeRebaseExitToPause } from "./parse-todo-of-stacked-rebase/validator"; +import { assertNever } from "./util/assertNever"; +import { Single, Tuple } from "./util/tuple"; +import { + GoodCommand, + GoodCommandStacked, // + namesOfRebaseCommandsThatMakeRebaseExitToPause, + StackedRebaseCommand, + StackedRebaseCommandAlias, +} from "./parse-todo-of-stacked-rebase/validator"; // console.log = () => {}; @@ -466,25 +474,198 @@ export const gitStackedRebase = async ( const goodCommands: GoodCommand[] = parseTodoOfStackedRebase(pathToStackedRebaseTodoFile); - const proms: Promise[] = goodCommands.map(async (cmd) => { - // if (cmd.commandName === "pick") { + // eslint-disable-next-line no-inner-declarations + async function createBranchForCommand( + cmd: GoodCommand & { commandName: StackedRebaseCommand & "branch-end-new" } + ): Promise { + const newBranchName: string = cmd.targets![0]; + const force: number = 0; + // eslint-disable-next-line @typescript-eslint/no-non-null-assertion + const targetCommitSHA: string = cmd.commitSHAThatBranchPointsTo!; + const targetCommit: Git.Commit = await Git.Commit.lookup(repo, targetCommitSHA); + await Git.Branch.create(repo, newBranchName, targetCommit, force); + } + + /** + * TODO should probably go into `validator` + */ + const oldLatestBranchCmdIndex: number = goodCommands.findIndex((cmd) => cmd.commandName === "branch-end-last"); + // if (indexOfLatestBranch === -1) // TODO verify in validator + + const isThereANewLatestBranch: boolean = oldLatestBranchCmdIndex !== goodCommands.length - 1; + + if (isThereANewLatestBranch) { + let newLatestBranchCmdIndex: number | null = null; + let userOnlyReorderedWithoutCreatingNew: boolean = false; + for (let i = goodCommands.length - 1; i >= 0; i--) { + const cmd = goodCommands[i]; + if (cmd.commandName === "branch-end-new") { + newLatestBranchCmdIndex = i; + break; + } + } + if (newLatestBranchCmdIndex === null || newLatestBranchCmdIndex <= oldLatestBranchCmdIndex) { + /** + * check if wanted to re-order w/o creating a new branch + */ + const hasExistingBranchAsLatest: boolean = + goodCommands[goodCommands.length - 1].commandName === "branch-end"; + + if (newLatestBranchCmdIndex === null && hasExistingBranchAsLatest) { + newLatestBranchCmdIndex = goodCommands.length - 1; + userOnlyReorderedWithoutCreatingNew = true; + } else { + // TODO validator + const when = + newLatestBranchCmdIndex === null + ? "at all" + : newLatestBranchCmdIndex <= oldLatestBranchCmdIndex + ? "after the branch-end-latest command" + : ""; // assertNever(newLatestBranchCmdIndex); + + throw new Termination( + "\n" + + `apparently a new latest branch was attempted (by adding commands _after_ the "branch-end-last")` + + `\nbut there was no "branch-end-new" command (${when})` + ); + } + } - // } + /** + * strategy: + * + * 1. create the "branch-end-new" at the appropriate position + * + * now, both the new & the old "latest" branches are pointing to the same commit + * + * 2. reset the old "latest" branch to the newly provided, earlier position + * + * 3. update the command names of the 2 branches + * 3.1 in "goodCommands" + * 3.2 in our "git-rebase-todo" file? + * + * + * strategy v2: + * 1. same + * 2. + * + * + * note 1: + * in general, idk if this is the best approach. + * though, it requries the least amount of effort from the user, afaik. + * + * if we instead made the user manually move the "branch-end-latest" to an earlier position (normal / same as here), + * but the also rename it to "branch-end" (extra work), + * and then create a new "branch-end-latest" in the very end (normal / similar to here (just "-latest" instead of "-new")), + * + * it's more steps, and idk if it conveys the picture well, + * because we no longer say "branch-end-new" explicitly, + * nor is it explicit that the "branch-end-last" has been moved. + * + * so then yes, the alternative sucks, + * & this (branch-end-last being not the latest command) is good. + * + * note 2: + * TODO will most likely need to do extra handling for the `rebaseChangedLocalHistory`, + * because even tho we won't change local history _of the commits_, + * the branches do indeed change, and here it's not simply adding a branch in the middle + * (though does that also need extra handling?), + * we're changing the latest branch, so it matters a lot + * and would need to run the `--apply` + * (currently `rebaseChangedLocalHistory` would prevent `--apply` from running). + * + * note 2.1: + * TODO need to support a use-case where the new latest branch + * is not new, i.e. user has had already created it, + * and now has simply moved it after the "branch-end-last". + * + * note 3: + * this logic implies that we should always be doing `--apply`, + * TODO thus consider. + * + */ + const oldLatestBranchCmd: GoodCommandStacked = goodCommands[oldLatestBranchCmdIndex] as GoodCommandStacked; // TODO TS + const newLatestBranchCmd: GoodCommandStacked = goodCommands[newLatestBranchCmdIndex] as GoodCommandStacked; // TODO TS + if (!userOnlyReorderedWithoutCreatingNew) { + /** + * create the new "latest branch" + */ + await createBranchForCommand(newLatestBranchCmd as any); // TODO TS + } + + /** + * move the old "latest branch" earlier to it's target + */ + await repo.checkoutBranch(oldLatestBranchCmd.targets![0]); + const commit: Git.Commit = await Git.Commit.lookup(repo, oldLatestBranchCmd.commitSHAThatBranchPointsTo!); + await Git.Reset.reset(repo, commit, Git.Reset.TYPE.HARD, {}); + + /** + * go to the new "latest branch". + */ + await repo.checkoutBranch(newLatestBranchCmd.targets![0]); + + /** + * TODO FIXME don't do this so hackishly lmao + */ + const editedRebaseTodo: string = fs.readFileSync(pathToStackedRebaseTodoFile, { encoding: "utf-8" }); + const linesOfEditedRebaseTodo: string[] = editedRebaseTodo.split("\n"); + + replaceCommandInText(oldLatestBranchCmd, ["branch-end-last"], "branch-end"); + replaceCommandInText( + newLatestBranchCmd, // + userOnlyReorderedWithoutCreatingNew ? ["branch-end", "be"] : ["branch-end-new", "ben"], + "branch-end-last" + ); + + // eslint-disable-next-line no-inner-declarations + function replaceCommandInText( + cmd: GoodCommandStacked, // + allowedOldName: Single | Tuple, + newName: StackedRebaseCommand + ): void { + const words = linesOfEditedRebaseTodo[cmd.lineNumber].split(" "); + assert( + allowedOldName.some((n) => n === words[0]), + `invalid old name of command in git-rebase-todo file. got "${words[0]}", expected one of "${allowedOldName}".` + ); + words[0] = newName; + console.log({ before: linesOfEditedRebaseTodo[cmd.lineNumber] }); + linesOfEditedRebaseTodo[cmd.lineNumber] = words.join(" "); + console.log({ after: linesOfEditedRebaseTodo[cmd.lineNumber] }); + } + + fs.writeFileSync(pathToStackedRebaseTodoFile, linesOfEditedRebaseTodo.join("\n"), { encoding: "utf-8" }); + + /** + * TODO RE-PARSE ALL COMMANDS FROM THE FILE INSTEAD + */ + oldLatestBranchCmd.commandName = "branch-end"; + oldLatestBranchCmd.commandOrAliasName = "branch-end"; + + newLatestBranchCmd.commandName = "branch-end-last"; + newLatestBranchCmd.commandOrAliasName = "branch-end-last"; + + /** + * it's fine if the new "latest branch" does not have + * a remote set yet, because `--push` handles that, + * and we regardless wouldn't want to mess anything + * in the remote until `--push` is used. + */ + } + + for (const cmd of goodCommands) { if (cmd.rebaseKind === "regular") { regularRebaseTodoLines.push(cmd.fullLine); } else if (cmd.rebaseKind === "stacked") { if (cmd.commandName === "branch-end-new") { - const newBranchName: string = cmd.targets![0]; - const force: number = 0; - const targetCommitSHA: string = cmd.commitSHAThatBranchPointsTo!; - const targetCommit: Git.Commit = await Git.Commit.lookup(repo, targetCommitSHA); - await Git.Branch.create(repo, newBranchName, targetCommit, force); + await createBranchForCommand(cmd as any); // TODO TS } + } else { + assertNever(cmd); } - }); - - await Promise.all(proms); + } /** * libgit2's git rebase is sadly not very powerful diff --git a/parse-todo-of-stacked-rebase/parseNewGoodCommands.ts b/parse-todo-of-stacked-rebase/parseNewGoodCommands.ts index 1bf48dd5..096878a4 100644 --- a/parse-todo-of-stacked-rebase/parseNewGoodCommands.ts +++ b/parse-todo-of-stacked-rebase/parseNewGoodCommands.ts @@ -123,16 +123,20 @@ export function parseNewGoodCommands( } }; - if (goodOldCommand.index < goodCommandMinIndex) { + /** + * TODO `lineNumber` -- shouldn't it be `nthCommand`? + * need to experiment w/ e.g. "break", and comments. + */ + if (goodOldCommand.lineNumber < goodCommandMinIndex) { // TODO VERIFY console.warn( - `goodCommandOld.index (${goodOldCommand.index}) < goodCommandMinIndex (${goodCommandMinIndex}), continue'ing.` + `goodCommandOld.index (${goodOldCommand.lineNumber}) < goodCommandMinIndex (${goodCommandMinIndex}), continue'ing.` ); // goodCommandMinIndex++; continue; - } else if (goodOldCommand.index === goodCommandMinIndex) { + } else if (goodOldCommand.lineNumber === goodCommandMinIndex) { // perfect? // TODO VERIFY console.info(`index match`); diff --git a/parse-todo-of-stacked-rebase/validator.ts b/parse-todo-of-stacked-rebase/validator.ts index cc140573..3248a18e 100644 --- a/parse-todo-of-stacked-rebase/validator.ts +++ b/parse-todo-of-stacked-rebase/validator.ts @@ -144,6 +144,7 @@ export const regularRebaseCommands = { } as const; export type RegularRebaseCommand = keyof typeof regularRebaseCommands; +export type RegularRebaseEitherCommandOrAlias = RegularRebaseCommand | RegularRebaseCommandAlias; /** * TODO: assert each value is `RegularRebaseCommand`, @@ -227,8 +228,12 @@ const stackedRebaseCommandAliases = { ben: "branch-end-new", } as const; -type StackedRebaseCommandAlias = keyof typeof stackedRebaseCommandAliases; +export type StackedRebaseCommandAlias = keyof typeof stackedRebaseCommandAliases; +export type StackedRebaseEitherCommandOrAlias = StackedRebaseCommand | StackedRebaseCommandAlias; +/** + * combined + */ export type EitherRebaseCommand = RegularRebaseCommand | StackedRebaseCommand; export type EitherRebaseCommandAlias = RegularRebaseCommandAlias | StackedRebaseCommandAlias; @@ -260,16 +265,35 @@ export const namesOfRebaseCommandsThatMakeRebaseExitToPause: EitherRebaseCommand (cmd) => cmd.nameButNeverAlias ); -type BadCommand = { - command: string; +type LineNr = { + /** + * indexed from 0. + * counts comments/empty-lines/etc, see `nthCommand` instead + */ lineNumber: number; + + /** + * indexed from 1. + * counts comments/empty-lines/etc, see `nthCommand` instead + */ + humanLineNumber: number; + + /** + * indexed from 0. + * counts only commands + * (both good and bad, though irrelevant, because will error if has bad commands) + */ + nthCommand: number; +}; + +type BadCommand = LineNr & { + command: string; fullLine: string; reasons: string[]; }; -export type GoodCommandBase = { +export type GoodCommandBase = LineNr & { commandOrAliasName: EitherRebaseEitherCommandOrAlias; - lineNumber: number; fullLine: string; rest: string; /** @@ -277,8 +301,6 @@ export type GoodCommandBase = { * TODO: handle >1 */ targets: string[] | null; - index: number; - // commandName: EitherRebaseCommand; }; export type GoodCommandRegular = GoodCommandBase & { @@ -317,32 +339,36 @@ export function validate( return command in allEitherRebaseCommands || command in allEitherRebaseCommandAliases; } - let previousCommitSHA: string | null; + let previousCommitSHA: string | null = null; + let nthCommand: number = -1; /** * we're not processing command-by-command, we're processing line-by-line. */ - linesOfEditedRebaseTodo.forEach((fullLine, index) => { + for (let lineNumber = 0; lineNumber < linesOfEditedRebaseTodo.length; lineNumber++) { + const fullLine: string = linesOfEditedRebaseTodo[lineNumber]; + if (fullLine.startsWith("#")) { /** * ignore comments */ - return; + continue; } + nthCommand++; const [commandOrAliasName, ..._rest] = fullLine.split(" "); const rest = _rest.join(" "); - const lineNumber: number = index + 1; - if (!commandOrAliasExists(commandOrAliasName)) { badCommands.push({ command: commandOrAliasName, + nthCommand, lineNumber, + humanLineNumber: lineNumber + 1, fullLine, reasons: ["unrecognized command"], }); - return; + continue; } const commandName: EitherRebaseCommand = @@ -357,16 +383,16 @@ export function validate( const reasonsIfBad: string[] = []; if (enforceRequirementsSpecificToStackedRebase) { - if (index === 0) { + if (nthCommand === 0) { if (commandName !== "branch-end-initial") { reasonsIfBad.push("initial command must be `branch-end-initial`"); } } - if (index === linesOfEditedRebaseTodo.length - 1) { - if (commandName !== "branch-end-last") { - reasonsIfBad.push("last command must be `branch-end-last`"); - } - } + // if (index === linesOfEditedRebaseTodo.length - 1) { + // if (commandName !== "branch-end-last") { + // reasonsIfBad.push("last command must be `branch-end-last`"); + // } + // } } if (commandUsedAtLines[commandName].length > allEitherRebaseCommands[commandName].maxUseCount) { @@ -396,6 +422,8 @@ export function validate( badCommands.push({ command: commandName, lineNumber, + humanLineNumber: lineNumber + 1, + nthCommand, fullLine, reasons: reasonsIfBad, }); @@ -403,8 +431,9 @@ export function validate( goodCommands.push({ commandOrAliasName, targets, - index, lineNumber, + humanLineNumber: lineNumber + 1, + nthCommand, fullLine, rest, ...(commandName in regularRebaseCommands @@ -427,7 +456,7 @@ export function validate( previousCommitSHA = targets?.[0] ?? null; } } - }); + } if (badCommands.length) { throw new Termination( @@ -435,7 +464,7 @@ export function validate( joinWith("\n\n")([ "found errors in rebase commands:", ...badCommands.map((cmd) => - bullets(` line ${cmd.lineNumber}: ${tick(cmd.command)}`, cmd.reasons, " - ") + bullets(` line ${cmd.humanLineNumber}: ${tick(cmd.command)}`, cmd.reasons, " - ") ), "to edit & fix, use:", " git-stacked-rebase -e|--edit-todo\n", diff --git a/util/assertNever.ts b/util/assertNever.ts new file mode 100644 index 00000000..e461f7df --- /dev/null +++ b/util/assertNever.ts @@ -0,0 +1,3 @@ +export function assertNever(_x: never): never { + throw new Error(`assertNever called (with value ${_x}) - should've been disallowed at compile-time`); +} diff --git a/util/tuple.ts b/util/tuple.ts new file mode 100644 index 00000000..50faf324 --- /dev/null +++ b/util/tuple.ts @@ -0,0 +1,9 @@ +/** + * ...because eslint >5 sucks + */ + +export type Single = [A]; +export type ReadonlySingle = readonly [A]; + +export type Tuple = [A, B]; +export type ReadonlyTuple = readonly [A, B];