Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

feat: implement autoSquash! #18

Open
wants to merge 3 commits into
base: allow-multiple-branches-on-same-commit
Choose a base branch
from
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
243 changes: 243 additions & 0 deletions autosquash.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,243 @@
/* eslint-disable indent */

import Git from "nodegit";

import { CommitAndBranchBoundary } from "./git-stacked-rebase";

import { Termination } from "./util/error";
import { assertNever } from "./util/assertNever";

/**
* the general approach on how to handle autosquashing
* is the following, in order:
*
* 1. collect your commits,
* 2. extend them with branch boundaries,
* 3. re-order the "fixup!" and "squash!" commits,
* 4. convert from objects to strings that are joined
* with a newline and written to the git-rebase-todo file
*
*
* if we were to do (3) before (2)
* (which is what happens if we would use git's native rebase
* to collect the commits),
* then, in a situation where a commit with a "fixup!" or "squash!" subject
* is the latest commit of any branch in the stack,
* that commit will move not only itself, but it's branch as well.
*
* we don't want that obviously - we instead want the branch
* to point to a commit that was before the "fixup!" or "squash!" commit
* (and same applies if there were multiple "fixup!" / "squash!" commits in a row).
*
* see the `--no-autosquash` enforcement/limitation in the
* `getWantedCommitsWithBranchBoundariesUsingNativeGitRebase` function.
*
*/
export async function autosquash(
repo: Git.Repository, //
extendedCommits: CommitAndBranchBoundary[]
): Promise<CommitAndBranchBoundary[]> {
// type SHA = string;
// const commitLookupTable: Map<SHA, Git.Commit> = new Map();

const autoSquashableSummaryPrefixes = ["squash!", "fixup!"] as const;

/**
* we want to re-order the commits,
* but we do NOT want the branches to follow them.
*
* the easiest way to do this is to "un-attach" the branches from the commits,
* do the re-ordering,
* and then re-attach the branches to the new commits that are previous to the branch.
*/
const unattachedCommitsAndBranches: UnAttachedCommitOrBranch[] = unAttachBranchesFromCommits(extendedCommits);

for (let i = 0; i < unattachedCommitsAndBranches.length; i++) {
const commitOrBranch: UnAttachedCommitOrBranch = unattachedCommitsAndBranches[i];

if (isBranch(commitOrBranch)) {
continue;
}
const commit: UnAttachedCommit = commitOrBranch;

const summary: string = commit.commit.summary();
const hasAutoSquashablePrefix = (prefix: string): boolean => summary.startsWith(prefix);

const autoSquashCommandIdx: number = autoSquashableSummaryPrefixes.findIndex(hasAutoSquashablePrefix);
const shouldBeAutoSquashed = autoSquashCommandIdx !== -1;

if (!shouldBeAutoSquashed) {
continue;
}

const command = autoSquashableSummaryPrefixes[autoSquashCommandIdx];
const targetedCommittish: string = summary.split(" ")[1];

/**
* https://libgit2.org/libgit2/#HEAD/group/revparse
*/
// Git.Revparse.ext(target, )
const target: Git.Object = await Git.Revparse.single(repo, targetedCommittish);
const targetRev: Git.Object = await target.peel(Git.Object.TYPE.COMMIT);
const targetType: number = await targetRev.type();
const targetIsCommit: boolean = targetType === Git.Object.TYPE.COMMIT;

if (!targetIsCommit) {
const msg =
`\ntried to parse auto-squashable commit's target revision, but failed.` +
`\ncommit = ${commit.commit.sha()} (${commit.commit.summary()})` +
`\ncommand = ${command}` +
`\ntarget = ${targetRev.id().tostrS()}` +
`\ntarget type (expected ${Git.Object.TYPE.COMMIT}) = ${targetType}` +
`\n\n`;

throw new Termination(msg);
}

const indexOfTargetCommit: number = unattachedCommitsAndBranches.findIndex(
(c) => !isBranch(c) && !target.id().cmp(c.commit.id())
);
const wasNotFound = indexOfTargetCommit === -1;

if (wasNotFound) {
const msg =
`\ntried to re-order an auto-squashable commit, ` +
`but the target commit was not within the commits that are being rebased.` +
`\ncommit = ${commit.commit.sha()} (${commit.commit.summary()})` +
`\ncommand = ${command}` +
`\ntarget = ${targetRev.id().tostrS()}` +
`\n\n`;

throw new Termination(msg);
}

commit.commitCommand =
command === "squash!"
? "squash" //
: command === "fixup!"
? "fixup"
: assertNever(command);

/**
* first remove the commit from the array,
* and only then insert it in the array.
*
* this will always work, and the opposite will never work
* because of index mismatch:
*
* you cannot reference commit SHAs that will appear in the future,
* only in the past.
* thus, we know that an auto-squashable commit's target will always be
* earlier in the history than the auto-squashable commit itself.
*
* thus, we first remove the auto-squashable commit,
* so that the index of the target commit stays the same,
* and only then insert the auto-squashable commit.
*
*
* TODO optimal implementation with a linked list + a map
*
*/
unattachedCommitsAndBranches.splice(i, 1); // remove 1 element (`commit`)
unattachedCommitsAndBranches.splice(indexOfTargetCommit + 1, 0, commit); // insert the `commit` in the new position
}

const reattached: CommitAndBranchBoundary[] = reAttachBranchesToCommits(unattachedCommitsAndBranches);

return reattached;
}

type UnAttachedCommit = Omit<CommitAndBranchBoundary, "branchEnd">;
type UnAttachedBranch = Pick<CommitAndBranchBoundary, "branchEnd">;
type UnAttachedCommitOrBranch = UnAttachedCommit | UnAttachedBranch;

function isBranch(commitOrBranch: UnAttachedCommitOrBranch): commitOrBranch is UnAttachedBranch {
return "branchEnd" in commitOrBranch;
}

function unAttachBranchesFromCommits(attached: CommitAndBranchBoundary[]): UnAttachedCommitOrBranch[] {
const unattached: UnAttachedCommitOrBranch[] = [];

for (const { branchEnd, ...c } of attached) {
unattached.push(c);

if (branchEnd?.length) {
unattached.push({ branchEnd });
}
}

return unattached;
}

/**
* the key to remember here is that commits could've been moved around
* (that's the whole purpose of unattaching and reattaching the branches)
* (specifically, commits can only be moved back in history,
* because you cannot specify a SHA of a commit in the future),
*
* and thus multiple `branchEnd` could end up pointing to a single commit,
* which just needs to be handled.
*
*/
function reAttachBranchesToCommits(unattached: UnAttachedCommitOrBranch[]): CommitAndBranchBoundary[] {
const reattached: CommitAndBranchBoundary[] = [];

let branchEndsForCommit: NonNullable<UnAttachedBranch["branchEnd"]>[] = [];

for (let i = unattached.length - 1; i >= 0; i--) {
const commitOrBranch = unattached[i];

if (isBranch(commitOrBranch) && commitOrBranch.branchEnd?.length) {
/**
* it's a branchEnd. remember the above consideration
* that multiple of them can accumulate for a single commit,
* thus buffer them, until we reach a commit.
*/
branchEndsForCommit.push(commitOrBranch.branchEnd);
} else {
/**
* we reached a commit.
*/

let combinedBranchEnds: NonNullable<UnAttachedBranch["branchEnd"]> = [];

/**
* they are added in reverse order (i--). let's reverse branchEndsForCommit
*/
for (let j = branchEndsForCommit.length - 1; j >= 0; j--) {
const branchEnd: Git.Reference[] = branchEndsForCommit[j];
combinedBranchEnds = combinedBranchEnds.concat(branchEnd);
}

const restoredCommitWithBranchEnds: CommitAndBranchBoundary = {
...(commitOrBranch as UnAttachedCommit), // TODO TS assert
branchEnd: [...combinedBranchEnds],
};

reattached.push(restoredCommitWithBranchEnds);
branchEndsForCommit = [];
}
}

/**
* we were going backwards - restore correct order.
* reverses in place.
*/
reattached.reverse();

if (branchEndsForCommit.length) {
/**
* TODO should never happen,
* or we should assign by default to the 1st commit
*/

const msg =
`\nhave leftover branches without a commit to attach onto:` +
`\n${branchEndsForCommit.join("\n")}` +
`\n\n`;

throw new Termination(msg);
}

return reattached;
}
1 change: 1 addition & 0 deletions configKeys.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,4 +3,5 @@ export const configKeyPrefix = "stackedrebase" as const;
export const configKeys = {
gpgSign: "commit.gpgSign",
autoApplyIfNeeded: `${configKeyPrefix}.autoApplyIfNeeded`,
autoSquash: "rebase.autoSquash",
} as const;
14 changes: 11 additions & 3 deletions git-stacked-rebase.ts
Original file line number Diff line number Diff line change
Expand Up @@ -19,6 +19,7 @@ import { configKeys } from "./configKeys";
import { apply, applyIfNeedsToApply, markThatNeedsToApply as _markThatNeedsToApply } from "./apply";
import { forcePush } from "./forcePush";
import { BehaviorOfGetBranchBoundaries, branchSequencer } from "./branchSequencer";
import { autosquash } from "./autosquash";

import { createExecSyncInRepo } from "./util/execSyncInRepo";
import { noop } from "./util/noop";
Expand Down Expand Up @@ -138,6 +139,7 @@ export const gitStackedRebase = async (
const configValues = {
gpgSign: !!(await config.getBool(configKeys.gpgSign).catch(() => 0)),
autoApplyIfNeeded: !!(await config.getBool(configKeys.autoApplyIfNeeded).catch(() => 0)),
autoSquash: !!(await config.getBool(configKeys.autoSquash).catch(() => 0)),
} as const;

console.log({ configValues });
Expand Down Expand Up @@ -338,7 +340,8 @@ export const gitStackedRebase = async (
initialBranch,
currentBranch,
// __default__pathToStackedRebaseTodoFile
pathToStackedRebaseTodoFile
pathToStackedRebaseTodoFile,
configValues.autoSquash
// () =>
// getWantedCommitsWithBranchBoundariesUsingNativeGitRebase({
// gitCmd: options.gitCmd,
Expand Down Expand Up @@ -853,6 +856,7 @@ async function createInitialEditTodoOfGitStackedRebase(
initialBranch: Git.Reference,
currentBranch: Git.Reference,
pathToRebaseTodoFile: string,
autoSquash: boolean,
getCommitsWithBranchBoundaries: () => Promise<CommitAndBranchBoundary[]> = () =>
getWantedCommitsWithBranchBoundariesOurCustomImpl(
repo, //
Expand All @@ -867,7 +871,7 @@ async function createInitialEditTodoOfGitStackedRebase(
// return;
// }

const commitsWithBranchBoundaries: CommitAndBranchBoundary[] = await getCommitsWithBranchBoundaries();
let commitsWithBranchBoundaries: CommitAndBranchBoundary[] = await getCommitsWithBranchBoundaries();

// /**
// * TODO: FIXME HACK for nodegit rebase
Expand All @@ -884,6 +888,10 @@ async function createInitialEditTodoOfGitStackedRebase(

noop(commitsWithBranchBoundaries);

if (autoSquash) {
commitsWithBranchBoundaries = await autosquash(repo, commitsWithBranchBoundaries);
}

const rebaseTodo = commitsWithBranchBoundaries
.map(({ commit, commitCommand, branchEnd }, i) => {
if (i === 0) {
Expand Down Expand Up @@ -994,7 +1002,7 @@ function callAll(keyToFunctionMap: KeyToFunctionMap) {
);
}

type CommitAndBranchBoundary = {
export type CommitAndBranchBoundary = {
commit: Git.Commit;
commitCommand: RegularRebaseEitherCommandOrAlias;
branchEnd: Git.Reference[] | null;
Expand Down