Files
fluxscape/packages/noodl-git/src/core/stash.ts
Michael Cartner b9c60b07dc Initial commit
Co-Authored-By: Eric Tuvesson <eric.tuvesson@gmail.com>
Co-Authored-By: mikaeltellhed <2311083+mikaeltellhed@users.noreply.github.com>
Co-Authored-By: kotte <14197736+mrtamagotchi@users.noreply.github.com>
Co-Authored-By: Anders Larsson <64838990+anders-topp@users.noreply.github.com>
Co-Authored-By: Johan  <4934465+joolsus@users.noreply.github.com>
Co-Authored-By: Tore Knudsen <18231882+torekndsn@users.noreply.github.com>
Co-Authored-By: victoratndl <99176179+victoratndl@users.noreply.github.com>
2024-01-26 11:52:55 +01:00

243 lines
7.3 KiB
TypeScript

import { GitError as DugiteError } from "dugite";
import { git } from "./client";
import { GitError } from "./git-error";
import {
Stash,
StashedChangesLoadStates,
StashedFileChanges,
} from "./models/snapshot";
import { Branch } from "./models/branch";
import { CommitIdentity } from "./models/commit-identity";
import { GitActionError, GitActionErrorCode } from "../actions";
/**
* RegEx for determining if a stash entry is created by Desktop
*
* This is done by looking for a magic string with the following
* formats:
* `On branchname: some message`
* `WIP on branchname: some message` (git default when message is omitted)
*/
const desktopStashEntryMessageRe = /on (.+):/i;
type StashResult = {
/** The stash entries created by Desktop */
readonly entries: ReadonlyArray<Stash>;
/**
* The total amount of stash entries,
* i.e. stash entries created both by Desktop and outside of Desktop
*/
readonly stashEntryCount: number;
};
/**
* Get the list of stash entries created by Desktop in the current repository
* using the default ordering of refs (which is LIFO ordering),
* as well as the total amount of stash entries.
*/
export async function getStashes(repositoryDir: string): Promise<StashResult> {
const delimiter = "1F";
const delimiterString = String.fromCharCode(parseInt(delimiter, 16));
const format = ["%gD", "%H", "%gs", "%an <%ae> %at +0000"].join(
`%x${delimiter}`
);
const result = await git(
["log", "-g", "-z", `--pretty=${format}`, "refs/stash"],
repositoryDir,
"getStashEntries",
{
successExitCodes: new Set([0, 128]),
}
);
// There's no refs/stashes reflog in the repository or it's not
// even a repository. In either case we don't care
if (result.exitCode === 128) {
return { entries: [], stashEntryCount: 0 };
}
const desktopStashEntries: Array<Stash> = [];
const files: StashedFileChanges = {
kind: StashedChangesLoadStates.NotLoaded,
};
const entries = result.output
.toString()
.split("\0")
.filter((s) => s !== "");
for (const entry of entries) {
const pieces = entry.split(delimiterString);
if (pieces.length === 4) {
const [name, stashSha, message, identity] = pieces;
const branchName = extractBranchFromMessage(message);
// Example: 'On main: !!Noodl<main>'
const marker = message.split(":")[1].trim();
if (branchName !== null) {
desktopStashEntries.push(
new Stash(
repositoryDir,
name,
branchName,
stashSha,
marker,
CommitIdentity.parseIdentity(identity),
files
)
);
}
}
}
return {
entries: desktopStashEntries,
stashEntryCount: entries.length - 1,
};
}
/**
* Returns the last Desktop created stash entry for the given branch
*/
export async function getLastStashEntryForBranch(
repositoryDir: string,
branch: Branch | string
) {
const stash = await getStashes(repositoryDir);
const branchName = typeof branch === "string" ? branch : branch.name;
// Since stash objects are returned in a LIFO manner, the first
// entry found is guaranteed to be the last entry created
return stash.entries.find((stash) => stash.branchName === branchName) || null;
}
/**
* Stash the working directory changes for the current branch
*/
export async function createStashEntry(
repositoryDir: string,
message: string
): Promise<Stash> {
const args = ["stash", "push", "-u", "-m", message];
const result = await git(args, repositoryDir, "createStashEntry", {
successExitCodes: new Set<number>([0, 1]),
});
if (result.exitCode === 1) {
// search for any line starting with `error:` - /m here to ensure this is
// applied to each line, without needing to split the text
const errorPrefixRe = /^error: /m;
const matches = errorPrefixRe.exec(result.error.toString());
if (matches !== null && matches.length > 0) {
// rethrow, because these messages should prevent the stash from being created
throw new GitError(result, args);
}
// if no error messages were emitted by Git, we should log but continue because
// a valid stash was created and this should not interfere with the checkout
console.info(
`[createStashEntry] a stash was created successfully but exit code ${
result.exitCode
} reported. stderr: ${result.error.toString()}`
);
}
const response = result.output.toString();
// Stash doesn't consider it an error that there aren't any local changes to save.
if (response === "No local changes to save\n") {
throw new GitActionError(GitActionErrorCode.StashNoLocalChanges);
}
// Fetch all the stashes and return the one we just created.
const stashes = await getStashes(repositoryDir);
return stashes.entries.find((x) => x.message === message);
}
/**
* Removes the given stash entry if it exists
*
* @param marker
*/
export async function dropStashEntry(repositoryDir: string, marker: string) {
if (marker !== null) {
const args = ["stash", "drop", marker];
await git(args, repositoryDir, "dropStashEntry");
}
}
/**
* Pops the stash entry identified by matching `stashSha` to its commit hash.
*
* To see the commit hash of stash entry, run
* `git log -g refs/stash --pretty="%nentry: %gd%nsubject: %gs%nhash: %H%n"`
* in a repo with some stash entries.
*/
export async function popStashEntry(
repositoryDir: string,
marker: string
): Promise<void> {
// ignoring these git errors for now, this will change when we start
// implementing the stash conflict flow
const expectedErrors = new Set<DugiteError>([DugiteError.MergeConflicts]);
const successExitCodes = new Set<number>([0, 1]);
if (marker === null) {
return;
}
const args = ["stash", "pop", "--quiet", `${marker}`];
const result = await git(args, repositoryDir, "popStashEntry", {
expectedErrors,
successExitCodes,
spawn: false,
});
// popping a stashes that create conflicts in the working directory
// report an exit code of `1` and are not dropped after being applied.
// so, we check for this case and drop them manually
if (result.exitCode === 1) {
if (result.error.toString().length > 0) {
// rethrow, because anything in stderr should prevent the stash from being popped
throw new GitError(result, args);
}
console.info(
`[popStashEntry] a stash was popped successfully but exit code ${result.exitCode} reported.`
);
// bye bye
await dropStashEntry(repositoryDir, marker);
}
}
export async function popStashEntryToBranch(
repositoryDir: string,
marker: string,
branchName: string
) {
// ignoring these git errors for now, this will change when we start
// implementing the stash conflict flow
const expectedErrors = new Set<DugiteError>([DugiteError.MergeConflicts]);
const successExitCodes = new Set<number>([0, 1]);
const args = ["stash", "branch", branchName, `${marker}`];
const result = await git(args, repositoryDir, "popStashEntryToBranch", {
expectedErrors,
successExitCodes,
spawn: false,
});
if (result.exitCode === 1) {
throw new GitError(result, args);
}
}
function extractBranchFromMessage(message: string): string | null {
const match = desktopStashEntryMessageRe.exec(message);
return match === null || match[1].length === 0 ? null : match[1];
}