UNPKG

@deftomat/opinionated

Version:

Opinionated tooling for JavaScript & TypeScript projects.

215 lines (214 loc) 8.58 kB
/** * Partially copied from `lint-staged` project. */ import { deleteAsync } from 'del'; import { execa } from 'execa'; import { resolve } from 'node:path'; import { debug } from './utils.js'; /** * Provides Git related operations. */ export function createGitWorkflow(cwd) { let workingCopyTree = null; let indexTree = null; let formattedIndexTree = null; let _gitDir; function gitDir() { if (_gitDir === undefined) { // git cli uses GIT_DIR to fast track its response however it might be set to a different path // depending on where the caller initiated this from, hence clear GIT_DIR delete process.env.GIT_DIR; _gitDir = execGit(['rev-parse', '--show-toplevel'], { cwd }).then(path => path, () => undefined); } return _gitDir; } async function execGit(args, options) { debug('Running git command', args); try { const { stdout } = await execa('git', args, options); return stdout; } catch (err) { throw new Error(err); } } async function getStagedFiles() { const cwd = await gitDir(); const result = await execGit(['diff', '--staged', '--diff-filter=ACM', '--name-only', '--relative'], { cwd }); return result .split('\n') .map(path => path.trim()) .filter(path => path !== '') .map(path => resolve(`${cwd}/${path}`)); } async function writeTree() { return execGit(['write-tree'], { cwd: await gitDir() }); } async function getDiffForTrees(tree1, tree2) { debug(`Generating diff between trees ${tree1} and ${tree2}...`); return execGit([ 'diff-tree', '--ignore-submodules', '--binary', '--no-color', '--no-ext-diff', '--unified=0', tree1, tree2 ], { cwd: await gitDir() }); } async function hasPartiallyStagedFiles() { const stdout = await execGit(['status', '--porcelain'], { cwd: await gitDir() }); if (!stdout) return false; const changedFiles = stdout.split('\n'); const partiallyStaged = changedFiles.filter(line => { /** * See https://git-scm.com/docs/git-status#_short_format * The first letter of the line represents current index status, * and second the working tree */ const [index, workingTree] = line; return index !== ' ' && workingTree !== ' ' && index !== '?' && workingTree !== '?'; }); return partiallyStaged.length > 0; } // eslint-disable-next-line async function stashSave() { debug('Stashing files...'); // Save ref to the current index indexTree = await writeTree(); // Add working copy changes to index await execGit(['add', '.'], { cwd: await gitDir() }); // Save ref to the working copy index workingCopyTree = await writeTree(); // Restore the current index await execGit(['read-tree', indexTree], { cwd: await gitDir() }); // Remove all modifications await execGit(['checkout-index', '-af'], { cwd: await gitDir() }); // await execGit(['clean', '-dfx'], options) debug('Done stashing files!'); return [workingCopyTree, indexTree]; } async function updateStash() { formattedIndexTree = await writeTree(); return formattedIndexTree; } async function applyPatchFor(tree1, tree2) { const diff = await getDiffForTrees(tree1, tree2); /** * This is crucial for patch to work * For some reason, git-apply requires that the patch ends with the newline symbol * See http://git.661346.n2.nabble.com/Bug-in-Git-Gui-Creates-corrupt-patch-td2384251.html * and https://stackoverflow.com/questions/13223868/how-to-stage-line-by-line-in-git-gui-although-no-newline-at-end-of-file-warnin */ if (diff) { try { /** * Apply patch to index. We will apply it with --reject so it it will try apply hunk by hunk * We're not interested in failied hunks since this mean that formatting conflicts with user changes * and we prioritize user changes over formatter's */ await execGit(['apply', '-v', '--whitespace=nowarn', '--reject', '--recount', '--unidiff-zero'], { input: `${diff}\n`, cwd: await gitDir() }); } catch (err) { debug('Could not apply patch to the stashed files cleanly'); debug(err); debug('Patch content:'); debug(diff); throw new Error('Could not apply patch to the stashed files cleanly.' + err); } } } async function stashPop() { if (workingCopyTree === null) { throw new Error('Trying to restore from stash but could not find working copy stash.'); } debug('Restoring working copy'); // Restore the stashed files in the index await execGit(['read-tree', workingCopyTree], { cwd: await gitDir() }); // and sync it to the working copy (i.e. update files on fs) await execGit(['checkout-index', '-af'], { cwd: await gitDir() }); // Then, restore the index after working copy is restored if (indexTree !== null && formattedIndexTree === null) { // Restore changes that were in index if there are no formatting changes debug('Restoring index'); await execGit(['read-tree', indexTree], { cwd: await gitDir() }); } else { /** * There are formatting changes we want to restore in the index * and in the working copy. So we start by restoring the index * and after that we'll try to carry as many as possible changes * to the working copy by applying the patch with --reject option. */ debug('Restoring index with formatting changes'); await execGit(['read-tree', formattedIndexTree], { cwd: await gitDir() }); try { await applyPatchFor(indexTree, formattedIndexTree); } catch (err) { debug('Found conflicts between formatters and local changes. Formatters changes will be ignored for conflicted hunks.'); /** * Clean up working directory from *.rej files that contain conflicted hanks. * These hunks are coming from formatters so we'll just delete them since they are irrelevant. */ try { const rejFiles = await deleteAsync(['*.rej']); debug('Deleted files and folders:\n', rejFiles.join('\n')); } catch (delErr) { debug('Error deleting *.rej files', delErr); } } } // Clean up references /* eslint-disable */ workingCopyTree = null; indexTree = null; formattedIndexTree = null; /* eslint-enable */ return null; } async function stageFile(path) { return execGit(['add', path], { cwd: await gitDir() }); } async function isGitRepository() { try { await execGit(['rev-parse', '--git-dir']); return true; } catch (error) { return false; } } async function hasChanges() { const changed = await execGit(['status', '-uall', '--porcelain']); return changed.trim() !== ''; } async function ensureMinimumGitVersion() { const version = await execGit(['--version']); const match = version.match(/([0-9]+).([0-9]+).([0-9]+)/); if (match == null) throw Error('Failed to detect Git version!'); const [, major, minor] = match; if (Number(major) < 2 || (Number(major) === 2 && Number(minor) < 13)) { throw Error('Failed to run! Git >= 2.13.0 is required.'); } } return { stageFile, isGitRepository, stashSave, stashPop, hasPartiallyStagedFiles, updateStash, exec: execGit, getStagedFiles, hasChanges, ensureMinimumGitVersion }; }