diff --git a/apply.ts b/apply.ts index c37f976b..18c35261 100644 --- a/apply.ts +++ b/apply.ts @@ -4,17 +4,17 @@ import path from "path"; import Git from "nodegit"; import { createQuestion } from "./util/createQuestion"; -import { noop } from "./util/noop"; import { filenames } from "./filenames"; import { configKeys } from "./configKeys"; +// eslint-disable-next-line import/no-cycle import { BranchSequencerBase, // branchSequencer, ActionInsideEachCheckedOutBranch, - CallbackAfterDone, BranchSequencerArgsBase, } from "./branchSequencer"; +import { combineRewrittenLists } from "./reducePath"; export const apply: BranchSequencerBase = (args) => branchSequencer({ @@ -30,13 +30,12 @@ const defaultApplyAction: ActionInsideEachCheckedOutBranch = async ({ repo, // // targetBranch, targetCommitSHA, - cmd, isFinalCheckout, // execSyncInRepo, }) => { const commit: Git.Commit = await Git.Commit.lookup(repo, targetCommitSHA); - console.log("will reset because", cmd.commandOrAliasName, "to commit", commit.summary(), commit.sha()); + console.log("will reset to commit", commit.sha(), "(" + commit.summary() + ")"); console.log({ isFinalCheckout }); @@ -52,36 +51,6 @@ const defaultApplyAction: ActionInsideEachCheckedOutBranch = async ({ export const getBackupPathOfPreviousStackedRebase = (pathToStackedRebaseDirInsideDotGit: string): string => pathToStackedRebaseDirInsideDotGit + ".previous"; -/** - * disabled because `forcePush` also became a thing - * and it's no longer clear what marks a stacked-rebase "done", - * - * thus making it hard to work with the temporary/previous directories - * without introducing a good amount of bugs. - * - */ -const defaultApplyCallback__disabled: CallbackAfterDone = ({ - pathToStackedRebaseDirInsideDotGit, // -}): void => { - const backupPath: string = getBackupPathOfPreviousStackedRebase(pathToStackedRebaseDirInsideDotGit); - - /** - * backup dir just in case, but in inactive path - * (so e.g --apply won't go off again accidently) - */ - if (fs.existsSync(backupPath)) { - fs.rmdirSync(backupPath, { recursive: true }); - } - fs.renameSync(pathToStackedRebaseDirInsideDotGit, backupPath); - - // diffCommands.forEach((cmd) => { - // console.log({ cmd }); - // execSyncInRepo(cmd, { ...pipestdio(repo.workdir()) }); - // }); - // -}; -noop(defaultApplyCallback__disabled); - export type ReturnOfApplyIfNeedsToApply = { markThatNeedsToApply: () => void; } & ( @@ -99,56 +68,6 @@ export type ReturnOfApplyIfNeedsToApply = { userAllowedToApplyAndWeApplied: true; } ); - -const getPaths = ( - pathToStackedRebaseDirInsideDotGit: string // -) => - ({ - rewrittenListPath: path.join(pathToStackedRebaseDirInsideDotGit, filenames.rewrittenList), - needsToApplyPath: path.join(pathToStackedRebaseDirInsideDotGit, filenames.needsToApply), - appliedPath: path.join(pathToStackedRebaseDirInsideDotGit, filenames.applied), - } as const); - -export const markThatNeedsToApply = ( - pathToStackedRebaseDirInsideDotGit: string // -): void => - [getPaths(pathToStackedRebaseDirInsideDotGit)].map( - ({ rewrittenListPath, needsToApplyPath, appliedPath }) => ( - fs.existsSync(rewrittenListPath) - ? fs.copyFileSync(rewrittenListPath, needsToApplyPath) - : fs.writeFileSync(needsToApplyPath, ""), - fs.existsSync(appliedPath) && fs.unlinkSync(appliedPath), - void 0 - ) - )[0]; - -export const markThatApplied = (pathToStackedRebaseDirInsideDotGit: string): void => - [getPaths(pathToStackedRebaseDirInsideDotGit)].map( - ({ rewrittenListPath, needsToApplyPath, appliedPath }) => ( - fs.existsSync(needsToApplyPath) && fs.unlinkSync(needsToApplyPath), // - fs.copyFileSync(rewrittenListPath, appliedPath), - void 0 - ) - )[0]; - -const doesNeedToApply = (pathToStackedRebaseDirInsideDotGit: string): boolean => { - const { rewrittenListPath, needsToApplyPath, appliedPath } = getPaths(pathToStackedRebaseDirInsideDotGit); - - const needsToApplyPart1: boolean = fs.existsSync(needsToApplyPath); - if (needsToApplyPart1) { - return true; - } - - const needsToApplyPart2: boolean = fs.existsSync(appliedPath) - ? /** - * check if has been applied, but that apply is outdated - */ - !fs.readFileSync(appliedPath).equals(fs.readFileSync(rewrittenListPath)) - : false; - - return needsToApplyPart2; -}; - export async function applyIfNeedsToApply({ repo, pathToStackedRebaseTodoFile, @@ -211,3 +130,119 @@ export async function applyIfNeedsToApply({ markThatNeedsToApply: _markThatNeedsToApply, }; } + +const getPaths = ( + pathToStackedRebaseDirInsideDotGit: string // +) => + ({ + rewrittenListPath: path.join(pathToStackedRebaseDirInsideDotGit, filenames.rewrittenList), + needsToApplyPath: path.join(pathToStackedRebaseDirInsideDotGit, filenames.needsToApply), + appliedPath: path.join(pathToStackedRebaseDirInsideDotGit, filenames.applied), + gitRebaseTodoPath: path.join(pathToStackedRebaseDirInsideDotGit, filenames.gitRebaseTodo), + } as const); + +export const markThatNeedsToApply = ( + pathToStackedRebaseDirInsideDotGit: string // +): void => + [getPaths(pathToStackedRebaseDirInsideDotGit)].map( + ({ rewrittenListPath, needsToApplyPath, appliedPath }) => ( + fs.existsSync(rewrittenListPath) + ? fs.copyFileSync(rewrittenListPath, needsToApplyPath) + : fs.writeFileSync(needsToApplyPath, ""), + fs.existsSync(appliedPath) && fs.unlinkSync(appliedPath), + void 0 + ) + )[0]; + +export const markThatApplied = (pathToStackedRebaseDirInsideDotGit: string): void => + [getPaths(pathToStackedRebaseDirInsideDotGit)].map( + ({ rewrittenListPath, needsToApplyPath, gitRebaseTodoPath }) => ( + fs.existsSync(needsToApplyPath) && fs.unlinkSync(needsToApplyPath), // + /** + * need to check if the `rewrittenListPath` exists, + * because even if it does not, then the "apply" can still go through + * and "apply", by using the already .applied file, i.e. do nothing. + * + * TODO just do not run "apply" if the file doesn't exist? + * or is there a case where it's useful still? + * + */ + // fs.existsSync(rewrittenListPath) && fs.renameSync(rewrittenListPath, appliedPath), + // // fs.existsSync(rewrittenListPath) + // // ? fs.renameSync(rewrittenListPath, appliedPath) + // // : !fs.existsSync(appliedPath) && + // // (() => { + // // throw new Error("applying uselessly"); + // // })(), + fs.existsSync(rewrittenListPath) && fs.unlinkSync(rewrittenListPath), + fs.existsSync(gitRebaseTodoPath) && fs.unlinkSync(gitRebaseTodoPath), + fs.readdirSync(pathToStackedRebaseDirInsideDotGit).length === 0 && + fs.rmdirSync(pathToStackedRebaseDirInsideDotGit, { recursive: true }), + void 0 + ) + )[0]; + +const doesNeedToApply = (pathToStackedRebaseDirInsideDotGit: string): boolean => { + const { rewrittenListPath, needsToApplyPath, appliedPath } = getPaths(pathToStackedRebaseDirInsideDotGit); + + if (!fs.existsSync(rewrittenListPath)) { + /** + * nothing to apply + */ + return false; + } + + const needsToApplyPart1: boolean = fs.existsSync(needsToApplyPath); + if (needsToApplyPart1) { + return true; + } + + const needsToApplyPart2: boolean = fs.existsSync(appliedPath) + ? /** + * check if has been applied, but that apply is outdated + */ + !fs.readFileSync(appliedPath).equals(fs.readFileSync(rewrittenListPath)) + : false; + + return needsToApplyPart2; +}; + +export function readRewrittenListNotAppliedOrAppliedOrError( + repoPath: string +): { + pathOfRewrittenList: string; + pathOfRewrittenListApplied: string; + rewrittenListRaw: string; + /** + * you probably want these: + */ + combinedRewrittenList: string; + combinedRewrittenListLines: string[]; +} { + const pathOfRewrittenList: string = path.join(repoPath, "stacked-rebase", filenames.rewrittenList); + const pathOfRewrittenListApplied: string = path.join(repoPath, "stacked-rebase", filenames.applied); + + /** + * not combined yet + */ + let rewrittenListRaw: string; + if (fs.existsSync(pathOfRewrittenList)) { + rewrittenListRaw = fs.readFileSync(pathOfRewrittenList, { encoding: "utf-8" }); + } else if (fs.existsSync(pathOfRewrittenListApplied)) { + rewrittenListRaw = fs.readFileSync(pathOfRewrittenListApplied, { encoding: "utf-8" }); + } else { + throw new Error( + `rewritten-list not found neither in ${pathOfRewrittenList}, nor in ${pathOfRewrittenListApplied}` + ); + } + + const { combinedRewrittenList } = combineRewrittenLists(rewrittenListRaw); + + return { + pathOfRewrittenList, + pathOfRewrittenListApplied, + rewrittenListRaw, + combinedRewrittenList, + combinedRewrittenListLines: combinedRewrittenList.split("\n").filter((line) => !!line), + }; +} diff --git a/branchSequencer.ts b/branchSequencer.ts index 799d8908..264efa62 100644 --- a/branchSequencer.ts +++ b/branchSequencer.ts @@ -7,36 +7,109 @@ import { createExecSyncInRepo } from "./util/execSyncInRepo"; import { Termination } from "./util/error"; import { parseNewGoodCommands } from "./parse-todo-of-stacked-rebase/parseNewGoodCommands"; -import { GoodCommand } from "./parse-todo-of-stacked-rebase/validator"; +import { GoodCommand, GoodCommandStacked } from "./parse-todo-of-stacked-rebase/validator"; -export type ActionInsideEachCheckedOutBranch = (ctx: ArgsForActionInsideEachCheckedOutBranch) => void | Promise; +export type GetBranchesCtx = { + pathToStackedRebaseDirInsideDotGit: string; + rootLevelCommandName: string; + repo: Git.Repository; + pathToStackedRebaseTodoFile: string; +}; +export type SimpleBranchAndCommit = { + commitSHA: string | null; + branchEndFullName: string; + // branchExistsYet: boolean; // TODO +}; +export type GetBoundariesInclInitial = ( + ctx: GetBranchesCtx // +) => SimpleBranchAndCommit[] | Promise; + +const defautlGetBoundariesInclInitial: GetBoundariesInclInitial = ({ + pathToStackedRebaseDirInsideDotGit, // + rootLevelCommandName, + repo, + pathToStackedRebaseTodoFile, +}) => { + /** + * TODO REMOVE / modify this logic (see next comment) + */ + if (!fs.existsSync(pathToStackedRebaseDirInsideDotGit)) { + throw new Termination(`\n\nno stacked-rebase in progress? (nothing to ${rootLevelCommandName})\n\n`); + } + // const hasPostRewriteHookInstalledWithLatestVersion = false; + + /** + * + * this is only needed to get the branch names. + * + * we should instead have this as a function in the options, + * we should provide the default value, + * but allow the higher level command to overwrite it. + * + * use case differences: + * + * a) apply: + * + * needs (always or no?) to parse the new good commands + * + * b) push: + * + * since is only allowed after apply has been done, + * it doesn't actually care nor need to parse the new good commands, + * and instead can be done by simply going thru the branches + * that you would normally do with `getWantedCommitsWithBranchBoundaries`. + * + * and so it can be used even if the user has never previously used stacked rebase! + * all is needed is the `initialBranch` and the current commit, + * so that we find all the previous branches up until `initialBranch` + * and just push them! + * + * and this is safe because again, if there's something that needs to be applied, + * then before you can push, you'll need to apply first. + * + * otherwise, you can push w/o any need of apply, + * or setting up the intial rebase todo, or whatever else, + * because it's not needed! + * + * --- + * + * this is also good because we become less stateful / need less state + * to function properly. + * + * it very well could get rid of some bugs / impossible states + * that we'd sometimes end up in. + * (and no longer need to manually rm -rf .git/stacked-rebase either) + * + */ + const stackedRebaseCommandsNew: GoodCommand[] = parseNewGoodCommands(repo, pathToStackedRebaseTodoFile); + + for (const cmd of stackedRebaseCommandsNew) { + assert(cmd.rebaseKind === "stacked"); + assert(cmd.targets?.length); + } + + return (stackedRebaseCommandsNew // + .filter((cmd) => cmd.rebaseKind === "stacked") as GoodCommandStacked[]) // + .map( + (cmd): SimpleBranchAndCommit => ({ + commitSHA: cmd.commitSHAThatBranchPointsTo, + branchEndFullName: cmd.targets![0], + }) + ); +}; /** * */ -export type ArgsForActionInsideEachCheckedOutBranch = { +export type ActionInsideEachCheckedOutBranchCtx = { repo: Git.Repository; // targetBranch: string; targetCommitSHA: string; - cmd: GoodCommand; isFinalCheckout: boolean; execSyncInRepo: ReturnType; }; - -/** - * - */ - -export type CtxForCallbackAfterDone = { - pathToStackedRebaseDirInsideDotGit: string; -}; - -export type CallbackAfterDone = (ctx: CtxForCallbackAfterDone) => void | Promise; - -/** - * - */ +export type ActionInsideEachCheckedOutBranch = (ctx: ActionInsideEachCheckedOutBranchCtx) => void | Promise; export type BranchSequencerArgsBase = { pathToStackedRebaseDirInsideDotGit: string; // @@ -45,13 +118,18 @@ export type BranchSequencerArgsBase = { repo: Git.Repository; rootLevelCommandName: string; gitCmd: string; + // + initialBranch: Git.Reference; + currentBranch: Git.Reference; }; - export type BranchSequencerArgs = BranchSequencerArgsBase & { // callbackBeforeBegin?: CallbackAfterDone; // TODO actionInsideEachCheckedOutBranch: ActionInsideEachCheckedOutBranch; delayMsBetweenCheckouts?: number; - callbackAfterDone?: CallbackAfterDone; + /** + * + */ + getBoundariesInclInitial?: GetBoundariesInclInitial; }; export type BranchSequencerBase = (args: BranchSequencerArgsBase) => Promise; @@ -59,90 +137,51 @@ export type BranchSequencer = (args: BranchSequencerArgs) => Promise; export const branchSequencer: BranchSequencer = async ({ pathToStackedRebaseDirInsideDotGit, // - // goodCommands, pathToStackedRebaseTodoFile, repo, rootLevelCommandName, delayMsBetweenCheckouts = 0, // callbackBeforeBegin, actionInsideEachCheckedOutBranch, - callbackAfterDone = (): void => {}, gitCmd, + // + getBoundariesInclInitial = defautlGetBoundariesInclInitial, }) => { - if (!fs.existsSync(pathToStackedRebaseDirInsideDotGit)) { - throw new Termination(`\n\nno stacked-rebase in progress? (nothing to ${rootLevelCommandName})\n\n`); - } - - const stackedRebaseCommandsNew: GoodCommand[] = parseNewGoodCommands(repo, pathToStackedRebaseTodoFile); - - // const remotes: Git.Remote[] = await repo.getRemotes(); - // const remote: Git.Remote | undefined = remotes.find((r) => - // stackedRebaseCommandsOld.find((cmd) => cmd.targets && cmd.targets[0].includes(r.name())) - // ); - - // const diffCommands: string[] = stackedRebaseCommandsOld - // .map((cmd, idx) => { - // const otherCmd: GoodCommand = stackedRebaseCommandsNew[idx]; - // assert(cmd.commandName === otherCmd.commandName); - // assert(cmd.targets?.length); - // assert(otherCmd.targets?.length); - // assert(cmd.targets.every((t) => otherCmd.targets?.every((otherT) => t === otherT))); - - // const trim = (str: string): string => str.replace("refs/heads/", "").replace("refs/remotes/", ""); - - // return !remote || idx === 0 // || idx === stackedRebaseCommandsOld.length - 1 - // ? "" - // : `git -c core.pager='' diff -u ${remote.name()}/${trim(cmd.targets[0])} ${trim( - // otherCmd.targets[0] - // )}`; - // }) - // .filter((cmd) => !!cmd); - - /** - * first actually reset, only then diff - */ - - // const commitsWithBranchBoundaries: CommitAndBranchBoundary[] = ( - // await getWantedCommitsWithBranchBoundaries( - // repo, // - // initialBranch - // ) - // ).reverse(); + const execSyncInRepo = createExecSyncInRepo(repo); - // const previousTargetBranchName: string = stackedRebaseCommandsNew[0] - // ? stackedRebaseCommandsNew[0].targets?.[0] ?? "" - // : ""; + const branchesAndCommits: SimpleBranchAndCommit[] = await getBoundariesInclInitial({ + pathToStackedRebaseDirInsideDotGit, + pathToStackedRebaseTodoFile, + repo, + rootLevelCommandName, + }); - const execSyncInRepo = createExecSyncInRepo(repo); + return checkout(branchesAndCommits.slice(1) as any); // TODO TS - const checkout = async (cmds: GoodCommand[]): Promise => { - if (!cmds.length) { + async function checkout(boundaries: SimpleBranchAndCommit[]): Promise { + if (!boundaries.length) { return; } - console.log("\ncheckout", cmds.length); + console.log("\ncheckout", boundaries.length); const goNext = () => new Promise((r) => { setTimeout(() => { - checkout(cmds.slice(1)).then(() => r()); + checkout(boundaries.slice(1)).then(() => r()); }, delayMsBetweenCheckouts); }); - const cmd = cmds[0]; - - assert(cmd.rebaseKind === "stacked"); - - const targetCommitSHA: string | null = cmd.commitSHAThatBranchPointsTo; + const boundary = boundaries[0]; + const branch = boundary.branchEndFullName; + const targetCommitSHA: string | null = boundary.commitSHA; if (!targetCommitSHA) { return goNext(); } - assert(cmd.targets?.length); - - let targetBranch = cmd.targets[0].replace("refs/heads/", ""); - assert(targetBranch && typeof targetBranch === "string"); + let targetBranch = branch.replace("refs/heads/", ""); + assert(targetBranch); /** * if we only have the remote branch, but it's not checked out locally, @@ -199,7 +238,7 @@ export const branchSequencer: BranchSequencer = async ({ /** * meaning we're on the latest branch */ - const isFinalCheckout: boolean = cmds.length === 1; + const isFinalCheckout: boolean = boundaries.length === 1; /** * https://libgit2.org/libgit2/#HEAD/group/checkout/git_checkout_head @@ -211,22 +250,10 @@ export const branchSequencer: BranchSequencer = async ({ repo, // targetBranch, targetCommitSHA, - cmd, isFinalCheckout, execSyncInRepo, }); return goNext(); - - // for (const cmd of stackedRebaseCommandsNew) { - // }; - }; - - await checkout(stackedRebaseCommandsNew.slice(1) as any); // TODO TS - - await callbackAfterDone({ - pathToStackedRebaseDirInsideDotGit, - }); - - return; + } }; diff --git a/filenames.ts b/filenames.ts index 518504d4..8ac1f7ea 100644 --- a/filenames.ts +++ b/filenames.ts @@ -5,7 +5,7 @@ export const filenames = { rewrittenList: "rewritten-list", willNeedToApply: "will-need-to-apply", needsToApply: "needs-to-apply", - applied: "applied", + applied: "rewritten-list.applied", // gitRebaseTodo: "git-rebase-todo", // diff --git a/forcePush.ts b/forcePush.ts index a7c0befa..aca88579 100644 --- a/forcePush.ts +++ b/forcePush.ts @@ -2,9 +2,11 @@ // import fs from "fs"; +import { getWantedCommitsWithBranchBoundariesOurCustomImpl } from "./git-stacked-rebase"; import { branchSequencer, // BranchSequencerBase, + SimpleBranchAndCommit, // getBackupPathOfPreviousStackedRebase, } from "./branchSequencer"; @@ -38,4 +40,19 @@ export const forcePush: BranchSequencerBase = (argsBase) => execSyncInRepo(`${argsBase.gitCmd} push --force`); }, delayMsBetweenCheckouts: 0, + getBoundariesInclInitial: () => + getWantedCommitsWithBranchBoundariesOurCustomImpl( + argsBase.repo, // + argsBase.initialBranch, + argsBase.currentBranch + ).then((boundaries) => + boundaries + .filter((b) => !!b.branchEnd) + .map( + (boundary): SimpleBranchAndCommit => ({ + branchEndFullName: boundary.branchEnd!.name(), // TS ok because of the filter + commitSHA: boundary.commit.sha(), + }) + ) + ), }); diff --git a/git-stacked-rebase.ts b/git-stacked-rebase.ts index 07e4a2f1..c23a7935 100755 --- a/git-stacked-rebase.ts +++ b/git-stacked-rebase.ts @@ -289,6 +289,17 @@ export const gitStackedRebase = async ( const checkIsRegularRebaseStillInProgress = (): boolean => fs.existsSync(pathToRegularRebaseDirInsideDotGit); + const initialBranch: Git.Reference | void = await Git.Branch.lookup( + repo, // + nameOfInitialBranch, + Git.Branch.BRANCH.ALL + ); + if (!initialBranch) { + throw new Error("initialBranch lookup failed"); + } + + const currentBranch: Git.Reference = await repo.getCurrentBranch(); + if (fs.existsSync(path.join(pathToStackedRebaseDirInsideDotGit, filenames.willNeedToApply))) { _markThatNeedsToApply(pathToStackedRebaseDirInsideDotGit); } @@ -300,6 +311,8 @@ export const gitStackedRebase = async ( pathToStackedRebaseDirInsideDotGit, // rootLevelCommandName: "--apply", gitCmd: options.gitCmd, + initialBranch, + currentBranch, }); } @@ -327,6 +340,8 @@ export const gitStackedRebase = async ( gitCmd: options.gitCmd, autoApplyIfNeeded: configValues.autoApplyIfNeeded, config, + initialBranch, + currentBranch, }); return; @@ -340,6 +355,8 @@ export const gitStackedRebase = async ( gitCmd: options.gitCmd, autoApplyIfNeeded: configValues.autoApplyIfNeeded, config, + initialBranch, + currentBranch, }); if (neededToApply && !userAllowedToApplyAndWeApplied) { @@ -357,6 +374,8 @@ export const gitStackedRebase = async ( pathToStackedRebaseDirInsideDotGit, rootLevelCommandName: "--push --force", gitCmd: options.gitCmd, + initialBranch, + currentBranch, }); } @@ -371,6 +390,8 @@ export const gitStackedRebase = async ( actionInsideEachCheckedOutBranch: ({ execSyncInRepo: execS }) => (execS(toExec), void 0), pathToStackedRebaseDirInsideDotGit, pathToStackedRebaseTodoFile, + initialBranch, + currentBranch, }); } else { /** @@ -382,15 +403,6 @@ export const gitStackedRebase = async ( } } - fs.mkdirSync(pathToStackedRebaseDirInsideDotGit, { recursive: true }); - - const initialBranch: Git.Reference | void = await Git.Branch.lookup( - repo, // - nameOfInitialBranch, - Git.Branch.BRANCH.ALL - ); - const currentBranch: Git.Reference = await repo.getCurrentBranch(); - const wasRegularRebaseInProgress: boolean = checkIsRegularRebaseStillInProgress(); // const @@ -400,6 +412,14 @@ export const gitStackedRebase = async ( throw new Termination("regular rebase already in progress"); } + /** + * only create the dir now, when it's needed. + * otherwise, other commands can incorrectly infer + * that our own stacked rebase is in progress, + * when it's not, up until now. + */ + fs.mkdirSync(pathToStackedRebaseDirInsideDotGit, { recursive: true }); + await createInitialEditTodoOfGitStackedRebase( repo, // initialBranch, @@ -631,9 +651,30 @@ REWRITTEN_LIST_FILE_PATH="$REBASE_MERGE_DIR/${filenames.rewrittenList}" STACKED_REBASE_DIR="$(pwd)/.git/stacked-rebase" REWRITTEN_LIST_BACKUP_FILE_PATH="$STACKED_REBASE_DIR/${filenames.rewrittenList}" +mkdir -p "$STACKED_REBASE_DIR" + #echo "REBASE_MERGE_DIR $REBASE_MERGE_DIR; STACKED_REBASE_DIR $STACKED_REBASE_DIR;" -cp "$REWRITTEN_LIST_FILE_PATH" "$REWRITTEN_LIST_BACKUP_FILE_PATH" +#cp "$REWRITTEN_LIST_FILE_PATH" "$REWRITTEN_LIST_BACKUP_FILE_PATH" + + +#cat >> "$REWRITTEN_LIST_BACKUP_FILE_PATH" <> "$REWRITTEN_LIST_BACKUP_FILE_PATH" <> "$REWRITTEN_LIST_BACKUP_FILE_PATH" < [-a|--apply] 2. but wil not push the partial branches to a remote until --push --force is used. -git-stacked-rebase [] (-c|--continue) - - (!) should be used instead of git-rebase's --continue - - ...because, additionally to invoking git rebase --continue, - this option automatically (prompts you to) --apply (if the rebase - has finished), thus ensuring that the partial branches - do not go out of sync with the newly rewritten history. - - git-stacked-rebase [--push|-p --force|-f] 1. will checkout each branch and will push --force, diff --git a/package.json b/package.json index 06c6e78a..5ddc1ccb 100644 --- a/package.json +++ b/package.json @@ -1,7 +1,7 @@ { "name": "git-stacked-rebase", "version": "0.6.1", - "main": "index.js", + "main": "dist/git-stacked-rebase.js", "repository": "git@github.com:kiprasmel/git-stacked-rebase.git", "author": "Kipras Melnikovas (https://kipras.org/)", "license": "UNLICENSED", diff --git a/parse-todo-of-stacked-rebase/parseNewGoodCommands.ts b/parse-todo-of-stacked-rebase/parseNewGoodCommands.ts index 12d8ad8f..1bf48dd5 100644 --- a/parse-todo-of-stacked-rebase/parseNewGoodCommands.ts +++ b/parse-todo-of-stacked-rebase/parseNewGoodCommands.ts @@ -1,13 +1,13 @@ /* eslint-disable indent */ -import fs from "fs"; -import path from "path"; import assert from "assert"; import Git from "nodegit"; import { array } from "nice-comment"; import { filenames } from "../filenames"; +// eslint-disable-next-line import/no-cycle +import { readRewrittenListNotAppliedOrAppliedOrError } from "../apply"; import { parseTodoOfStackedRebase } from "./parseTodoOfStackedRebase"; import { GoodCommand, stackedRebaseCommands } from "./validator"; @@ -20,18 +20,14 @@ export function parseNewGoodCommands( logGoodCmds(oldGoodCommands); - const pathOfRewrittenList: string = path.join(repo.path(), "stacked-rebase", filenames.rewrittenList); - const rewrittenList: string = fs.readFileSync(pathOfRewrittenList, { encoding: "utf-8" }); - const rewrittenListLines: string[] = rewrittenList.split("\n").filter((line) => !!line); - - console.log({ rewrittenListLines }); + const { combinedRewrittenListLines } = readRewrittenListNotAppliedOrAppliedOrError(repo.path()); const newCommits: { newSHA: string; oldSHAs: string[] }[] = []; type OldCommit = { oldSHA: string; newSHA: string; changed: boolean }; const oldCommits: OldCommit[] = []; - rewrittenListLines.map((line) => { + combinedRewrittenListLines.map((line) => { const fromToSHA = line.split(" "); assert( fromToSHA.length === 2, diff --git a/parse-todo-of-stacked-rebase/validator.ts b/parse-todo-of-stacked-rebase/validator.ts index 26b903f4..cc140573 100644 --- a/parse-todo-of-stacked-rebase/validator.ts +++ b/parse-todo-of-stacked-rebase/validator.ts @@ -267,7 +267,7 @@ type BadCommand = { reasons: string[]; }; -export type GoodCommand = { +export type GoodCommandBase = { commandOrAliasName: EitherRebaseEitherCommandOrAlias; lineNumber: number; fullLine: string; @@ -280,17 +280,17 @@ export type GoodCommand = { index: number; // commandName: EitherRebaseCommand; -} & ( - | { - rebaseKind: "regular"; - commandName: RegularRebaseCommand; - } - | { - rebaseKind: "stacked"; - commandName: StackedRebaseCommand; - commitSHAThatBranchPointsTo: string | null; - } -); +}; +export type GoodCommandRegular = GoodCommandBase & { + rebaseKind: "regular"; + commandName: RegularRebaseCommand; +}; +export type GoodCommandStacked = GoodCommandBase & { + rebaseKind: "stacked"; + commandName: StackedRebaseCommand; + commitSHAThatBranchPointsTo: string | null; +}; +export type GoodCommand = GoodCommandRegular | GoodCommandStacked; export function validate( linesOfEditedRebaseTodo: string[], // diff --git a/reducePath.spec.ts b/reducePath.spec.ts new file mode 100644 index 00000000..88d7320a --- /dev/null +++ b/reducePath.spec.ts @@ -0,0 +1,54 @@ +import assert from "assert"; + +import { reducePath } from "./reducePath"; + +export default function testcase() { + const obj1 = { + a: "b", + b: "c", + c: "d", + d: "e", + + g: "h", + + x: "x", + + y: "z", + z: "z", + + /** + * this might mean that we need to go backwards + * rather than forwards + * (multiple commits can be reported as rewritten into one, + * but i don't think the opposite is possible) + * + * ~~and/or we might need another phase, + * because currently, A -> F, + * and both B and C stay at D.~~ + * done + * + */ + A: "D", + B: "D", + C: "D", + D: "E", + E: "F", + }; + + reducePath(obj1); + console.log(obj1); + + assert.deepStrictEqual(obj1, { + a: "e", + + g: "h", + + x: "x", + + y: "z", + + A: "F", + B: "F", + C: "F", + }); +} diff --git a/reducePath.ts b/reducePath.ts new file mode 100755 index 00000000..66c36b41 --- /dev/null +++ b/reducePath.ts @@ -0,0 +1,359 @@ +#!/usr/bin/env ts-node-dev + +/* eslint-disable */ + +import assert from "assert" +import fs from "fs" +import { execSync } from "child_process" + +export type StringFromToMap = { [key: string]: string } + +/** + * mutates `obj` and returns it too + */ +export function reducePath(obj: StringFromToMap): StringFromToMap { + let prevSize : number = -Infinity + let entries : [string, string][] + let keysMarkedForDeletion: Set = new Set() + + // as long as it continues to improve + while (keysMarkedForDeletion.size > prevSize) { + prevSize = keysMarkedForDeletion.size + entries = Object.entries(obj) + + for (const [key, value] of entries) { + const keyIsValue = key === value + if (keyIsValue) { + // would delete itself, thus skip + continue + } + + // const gotReducedAlready = !(key in obj) + // if (gotReducedAlready) { + // continue + // } + + const valueIsAnotherKey = value in obj + if (valueIsAnotherKey) { + console.log("reducing. old:", key, "->", value, ";", value, "->", obj[value], "new:", key, "->", obj[value]) + // reduce + obj[key] = obj[value] + keysMarkedForDeletion.add(value) + } + } + } + + for (const key of keysMarkedForDeletion.keys()) { + delete obj[key] + } + + /** + * we mutate the object, so NOT returning it makes it clear + * that this function causes a side-effect (mutates the original object). + * + * but, in multiple cases when mapping, we forget to return the object, + * so instead we'll do it here: + */ + return obj +} + +export type RewrittenListBlockBase = { + mapping: StringFromToMap +} +export type RewrittenListBlockAmend = RewrittenListBlockBase & { + type: "amend" +} +export type RewrittenListBlockRebase = RewrittenListBlockBase & { + type: "rebase" +} +export type RewrittenListBlock = RewrittenListBlockAmend | RewrittenListBlockRebase + +export type CombineRewrittenListsRet = { + /** + * notice that this only includes rebases, no amends -- + * that's the whole point. + * + * further, probably only the 1st one is necessary, + * because it's likely that we'll start creating separate files for new rebases, + * or that might not be needed at all, because we might be able to + * --apply after every rebase, no matter if the user exited or not, + * thus we'd always have only 1 "rebase" block in the rewritten list. + */ + mergedReducedRewrittenLists: RewrittenListBlockRebase[], + + /** + * the git's standard represantation of the rewritten-list + * (no extras of ours) + */ + combinedRewrittenList: string, +} +export function combineRewrittenLists(rewrittenListFileContent: string): CombineRewrittenListsRet { + /** + * $1 (amend/rebase) + */ + const extraOperatorLineCount = 1 as const + + const rewrittenLists: RewrittenListBlock[] = rewrittenListFileContent + .split("\n\n") + .map(lists => lists.split("\n")) + .map(list => list[list.length - 1] === "" ? list.slice(0, -1) : list) + // .slice(0, -1) + .filter(list => list.length > extraOperatorLineCount) + .map((list): RewrittenListBlock => ({ + type: list[0] as RewrittenListBlock["type"], + mapping: Object.fromEntries( + list.slice(1).map(line => line.split(" ") as [string, string]) + ) + }) + ) + // .map(list => Object.fromEntries(list)) + console.log("rewrittenLists", rewrittenLists) + + let prev : RewrittenListBlockAmend[] = [] + let mergedReducedRewrittenLists: RewrittenListBlockRebase[] = [] + + let lastRebaseList: RewrittenListBlockRebase | null = null + + for (const list of rewrittenLists) { + if (list.type === "amend") { + prev.push(list) + } else if (list.type === "rebase") { + /** + * merging time + */ + for (const amend of prev) { + assert.equal(Object.keys(amend.mapping).length, 1) + + const [key, value] = Object.entries(amend.mapping)[0] + + /** + * (try to) merge + */ + if (key in list.mapping) { + if (value === list.mapping[key]) { + // pointless + continue + } else { + //throw new Error( + // `NOT IMPLEMENTED - identical key in 'amend' and 'rebase', but different values.` + //+ `(key = "${key}", amend's value = "${value}", rebase's value = "${list.mapping[key]}")` + //) + + /** + * amend + * A->B + * + * rebase + * A->C + * + * + * hmm. + * will we need to keep track of _when_ the post-rewrite happened as well? + * (i.e. on what commit) + * though, idk if that's possible, i think i already tried, + * but since the post-rewrite script is called _after_ the amend/rebase happens, + * it gives you the same commit that you already have, + * i.e. the already rewritten one, instead of the previous one... + * + */ + + /** + * for starters, we can try always favoring the amend over rebase + */ + Object.assign(list.mapping, amend.mapping) + + } + } else { + if (Object.values(list.mapping).includes(key)) { + if (Object.values(list.mapping).includes(value)) { + + console.warn(`value already in values`, { + [key]: value, + [Object.entries(list.mapping).find(([_k, v]) => v === value)![0]]: value, + }) + // continue + // throw; + + /** + * happened when: + * mark "edit" on commit A and B, + * reach commit A, + * do git commit --amend to change the title, + * continue to commit B, + * stop because of the another "edit", + * reset to HEAD~ (commit A) (changes kept in workdir), + * add all changes, + * git commit --amend them into commit A. + * + * how things ended up in the rewritten-list, was that: + * + * amend + * TMP_SHA -> NEW_SHA + * + * rebase + * COMMIT_A_SHA -> TMP_SHA + * COMMIT_B_SHA -> NEW_SHA + * + * + * and would end up as + * + * COMMIT_A_SHA -> NEW_SHA + * COMMIT_B_SHA -> NEW_SHA + * + * from our `git-rebase-todo` file, the ~~OLD_SHA_2~~ COMMIT_B_SHA was the original one found, + * BUT, it pointed to commit B, not commit A! + * + * there were more mappings in the rewritten-list that included the commit A's SHA... + * this is getting complicated. + * + * ---rm + * the 1st mapping of TMP_SHA -> NEW_SHA ended up first in the rewritten-list inside an "amend". + * the 2nd mapping of OLD_SHA_2 -> NEW_SHA ended up second in the rewritten-list inside the "rebase". + * --- + * + * + * TODO needs more testing. + * + * i mean, we very well could just get rid of the key->value pair + * if there exists another one with the same value, + * but how do we know which key to keep? + * + * wait... you keep the earliest key? + * + */ + // fwiw, i don't think this algo makes you keep the earliest key (or does it?) + Object.entries(list.mapping).forEach(([k, v]) => { + if (v === value && k !== key) { + // if it's not our key, delete it + // (our key will get assigned a new value below.) + console.info("deleting entry because duplicate A->B, C->A, D->B, ends up C->B, D->B, keeping only one", { + [k]: list.mapping[k], + }) + delete list.mapping[k] + } + }) + /** + * TODO test if you "fixup" (reset, add, amend) first -- + * does this reverse the order & you'd need the last key? + * + * TODO what if you did both and you need a key from the middle? lol + * + */ + } + + /** + * add the single new entry of amend's mapping into rebase's mapping. + * it will get `reducePath`'d later. + */ + Object.assign(list.mapping, amend.mapping) + } else { + if (Object.values(list.mapping).includes(value)) { + /** + * TODO needs more testing. + * especially which one is the actually newer one -- same questions apply as above. + */ + console.warn("the `rebase`'s mapping got a newer value than the amend, apparently. continuing.", { + [key]: value, + [Object.entries(list.mapping).find(([_k, v]) => v === value)![0]]: value, + }) + continue + } else { + console.warn( + "NOT IMPLEMENTED - neither key nor value of 'amend' was included in the 'rebase'." + + "\ncould be that we missed the ordering, or when we call 'reducePath', or something else.", + { + [key]: value, + }) + + /** + * i think this happens when commit gets rewritten, + * then amended, and amended again. + * + * looks like it's fine to ignore it. + */ + continue + } + } + } + } + + prev = [] + reducePath(list.mapping) + mergedReducedRewrittenLists.push(list) + lastRebaseList = list + } else { + throw new Error(`invalid list type (got "${(list as any).type}")`) + } + } + + if (prev.length) { + /** + * likely a rebase happenend first, + * it was not `--apply`ied, + * and then a `commit --amend` happend. + * + * if we don't handle this case, + * the changes done in the `--amend` + * would be lost. + */ + + if (!lastRebaseList) { + throw new Error(`NOT IMPLEMENTED - found "amend"(s) in rewritten-list, but did not find any "rebase"(s).`) + } + + for (const amend of prev) { + Object.assign(lastRebaseList.mapping, amend.mapping) + reducePath(lastRebaseList.mapping) + } + + prev = [] + } + + if (!lastRebaseList) { + throw new Error(`NOT IMPLEMENTED - did not find any "rebase"(s).`) + } + + /** + * TODO handle multiple rebases + * or, multiple separate files for each new rebase, + * since could potentially lose some info if skipping partial steps? + */ + + console.log("mergedReducedRewrittenLists", mergedReducedRewrittenLists) + + const combinedRewrittenList = Object.entries(lastRebaseList.mapping).map(([k, v]) => k + " " + v).join("\n") + "\n" + // fs.writeFileSync("rewritten-list", combinedRewrittenList) + + return { + mergedReducedRewrittenLists, + combinedRewrittenList, + } +} + +if (!module.parent) { + const prefix = "" // "test/.tmp-described.off/" + const rewrittenListFile = fs.readFileSync(prefix + ".git/stacked-rebase/rewritten-list", { encoding: "utf-8" }) + console.log({ rewrittenListFile }) + + const { mergedReducedRewrittenLists } = combineRewrittenLists(rewrittenListFile) + + const b4 = Object.keys(mergedReducedRewrittenLists[0].mapping) + const after = Object.values(mergedReducedRewrittenLists[0].mapping) + + const path = require("path") + const os = require("os") + const dir = path.join(os.tmpdir(), "gsr-reduce-path") + fs.mkdirSync(dir, { recursive: true }) + + const b4path = path.join(dir, "b4") + const afterpath = path.join(dir, "after") + fs.writeFileSync(b4path , b4 .join("\n") + "\n") + fs.writeFileSync(afterpath, after.join("\n") + "\n") + + const N = after.length + console.log({ N }) + + const currpath = path.join(dir, "curr") + execSync(`git log --pretty=format:"%H" | head -n ${N} | tac - > ${currpath}`) + execSync(`diff -us ${currpath} ${afterpath}`, { stdio: "inherit" }) +} diff --git a/test/run.ts b/test/run.ts index 7ffd3882..7b892ea1 100644 --- a/test/run.ts +++ b/test/run.ts @@ -1,10 +1,14 @@ #!/usr/bin/env ts-node-dev import { testCase } from "./experiment.spec"; +import reducePathTC from "../reducePath.spec"; main(); function main() { - testCase() + Promise.all([ + testCase(), // + reducePathTC(), + ]) .then(() => process.stdout.write("\nsuccess\n\n")) .catch((e) => { process.stderr.write("\nfailure: " + e + "\n\n");