Skip to content
Merged
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
125 changes: 122 additions & 3 deletions lib/git.util.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,9 +3,17 @@ import { promisify } from 'util';
import path from 'path';
import fs from 'fs/promises';
import replaceInFile from 'replace-in-file';
import { minimatch } from 'minimatch';

// #region shared with https://github.com/paranext/paranext-extension-template/blob/main/lib/git.util.ts

const execAsync = promisify(exec);

/** Absolute path to the repo root directory */
const repoRoot = path.resolve(path.join(__dirname, '..'));

// #endregion

/** The name for the multi-extension template remote as used in the git scripts */
export const MULTI_TEMPLATE_NAME = 'paranext-multi-extension-template';
/** The url for the multi-extension template remote as used in the git scripts */
Expand All @@ -19,6 +27,19 @@ export const SINGLE_TEMPLATE_URL = 'https://github.com/paranext/paranext-extensi
/** The branch to use in pulling changes from `SINGLE_TEMPLATE_REMOTE_NAME` in the git scripts */
export const SINGLE_TEMPLATE_BRANCH = 'main';

/** Cached npm workspaces list from root package.json, loaded once per process */
let cachedWorkspaces: string[] | undefined;

async function getWorkspaces(): Promise<string[]> {
if (cachedWorkspaces !== undefined) return cachedWorkspaces;
const content = await fs.readFile(path.join(repoRoot, 'package.json'), 'utf-8');
// JSON.parse returns unknown; we expect a package.json shape
// eslint-disable-next-line no-type-assertion/no-type-assertion
const packageJson = JSON.parse(content) as { workspaces?: string[] };
cachedWorkspaces = packageJson.workspaces ?? [];
return cachedWorkspaces;
}

// #region localization

/**
Expand Down Expand Up @@ -86,7 +107,7 @@ export async function execCommand(
if (!quiet) console.log(`\n>${execOptions.cwd ? ` cd ${execOptions.cwd};` : ''} ${command}`);
try {
const result = await execAsync(command, {
cwd: path.resolve(path.join(__dirname, '..')),
cwd: repoRoot,
...execOptions,
});
if (!quiet && result.stdout) console.log(result.stdout);
Expand Down Expand Up @@ -159,6 +180,78 @@ export async function fetchFromSingleTemplate() {
return true;
}

/**
* Returns true if the given repo-root-relative path is a `package-lock.json` file whose parent
* directory is an npm workspace under `src/`. Such files are unused (because the folder is a
* workspace) and are safe to delete automatically.
*
* @param repoRootRelativePath Repo-root-relative path, e.g. `src/hello-world/package-lock.json`
*/
export async function isUnusedWorkspacePackageLock(repoRootRelativePath: string): Promise<boolean> {
if (path.basename(repoRootRelativePath) !== 'package-lock.json') return false;
const parentDir = path.dirname(repoRootRelativePath);

// Must match a workspace pattern from the root package.json
const workspaces = await getWorkspaces();
return workspaces.some((pattern) => minimatch(parentDir, pattern));
}

/** Git status --porcelain v1 XY codes that indicate an unmerged (conflict) entry */
const CONFLICT_XY_CODES = new Set(['DD', 'AU', 'UD', 'UA', 'DU', 'AA', 'UU']);

/**
* After a `git subtree pull` or `git merge` fails, call this to auto-resolve any conflicts that are
* solely unused workspace `package-lock.json` files.
*
* Uses `git status --porcelain` (v1 format) — intentionally different from `checkForWorkingChanges`
* which uses `--porcelain=v2`. V1 is simpler for conflict-code parsing.
*
* For each conflicted `package-lock.json` that passes {@link isUnusedWorkspacePackageLock}, runs
* `git rm <path>` to delete and stage the file. Works for both:
*
* - `UU` (both modified): file is on disk with conflict markers
* - `DU` (deleted by us, modified by them): git leaves their version on disk during the conflict
*
* @returns `resolved` — number of lock files removed and staged. `remainingConflicts` —
* repo-root-relative paths of all OTHER conflicted files.
*/
export async function resolvePackageLockConflicts(): Promise<{
resolved: number;
remainingConflicts: string[];
}> {
const status = await execCommand('git status --porcelain', { quiet: true });

const lines = status.stdout.split('\n').filter((line) => line.length > 0);
const conflictLines = lines.filter((line) => CONFLICT_XY_CODES.has(line.slice(0, 2)));

const packageLockPaths: string[] = [];
const otherConflictPaths: string[] = [];

// Push order is non-deterministic across concurrent promises, but order doesn't matter here:
// all package-lock files get removed and remainingConflicts is only used for reporting.
await Promise.all(
conflictLines.map(async (line) => {
const filePath = line.slice(3); // skip "XY "
if (await isUnusedWorkspacePackageLock(filePath)) {
packageLockPaths.push(filePath);
} else {
otherConflictPaths.push(filePath);
}
}),
);

// Remove and stage each conflicted package-lock.json sequentially: each `git rm` must finish
// before the next to avoid interleaved git index updates.
// eslint-disable-next-line no-restricted-syntax
for (const filePath of packageLockPaths) {
// Intentional sequential await — see comment above the loop
// eslint-disable-next-line no-await-in-loop
await execCommand(`git rm "${filePath}"`);
}

return { resolved: packageLockPaths.length, remainingConflicts: otherConflictPaths };
}

/**
* Converts kebab-case into camelCase. Assumes that the input is a valid kebab-case string
*
Expand Down Expand Up @@ -190,6 +283,28 @@ function toCamelCaseFromKebab(input: string): string {
return camelCased;
}

/**
* Deletes a repo-root-relative path if it is an unused workspace `package-lock.json`. Silently
* skips if the file is absent.
*
* @param repoRootRelativePath Repo-root-relative path, e.g. `src/hello-world/package-lock.json`
*/
async function deleteUnusedPackageLockIfPresent(repoRootRelativePath: string): Promise<void> {
if (!(await isUnusedWorkspacePackageLock(repoRootRelativePath))) return;
try {
await fs.unlink(path.join(repoRoot, repoRootRelativePath));
console.log(`Deleted unused ${repoRootRelativePath}`);
} catch (error: unknown) {
// File not present — nothing to delete
if (!(error instanceof Error) || !('code' in error) || error.code !== 'ENOENT') throw error;
}
}

/** Format the repo root folder after a merge from the multi-extension template. */
export async function formatExtensionsRoot() {
// Currently a noop placeholder - add root-level formatting operations here in the future
}

/**
* Format an extension folder to make the extension template folder work as a subfolder of this repo
*
Expand All @@ -199,6 +314,9 @@ function toCamelCaseFromKebab(input: string): string {
* @param extensionFolderPath Path to the extension to format relative to root
*/
export async function formatExtensionFolder(extensionFolderPath: string) {
// Delete package-lock.json if present — it is unused because this folder is an npm workspace
await deleteUnusedPackageLockIfPresent(`${extensionFolderPath}/package-lock.json`);

// Get the basename of the extension folder for use in replacements
const extensionName = path.basename(extensionFolderPath);
const extensionNameCamelCase = toCamelCaseFromKebab(extensionName);
Expand All @@ -214,8 +332,9 @@ export async function formatExtensionFolder(extensionFolderPath: string) {
'**/.eslintcache',
'**/dist/**/*',
'**/release/**/*',
// With npm workspaces, child workspace package-lock.json files are not used. Let's not format
// them so they can stay the same as how they were in the template to avoid merge conflicts
// With npm workspaces, child workspace package-lock.json files are unused and are deleted
// proactively by formatExtensionFolder and formatExtensionsRoot. Skip them here in case they
// are present during a format pass before deletion runs.
'**/package-lock.json',
],
from: /([^/])\.\.\/paranext-core/g,
Expand Down
66 changes: 56 additions & 10 deletions lib/update-from-templates.ts
Original file line number Diff line number Diff line change
Expand Up @@ -8,9 +8,51 @@ import {
execCommand,
fetchFromSingleTemplate,
formatExtensionFolder,
formatExtensionsRoot,
resolvePackageLockConflicts,
} from './git.util';
import { ExtensionInfo, getExtensions } from '../webpack/webpack.util';

/**
* After a failed `git subtree pull` or `git merge`, attempts to auto-resolve any conflicts that are
* solely unused workspace `package-lock.json` files, then commits if fully resolved.
*
* @param context Human-readable label for error messages (template name or extension name)
* @param originalError The error thrown by the failed merge/pull
* @returns `'resolved'` if all conflicts were resolved and committed, `'failed'` otherwise (errors
* already logged)
*/
async function handleConflicts(
context: string,
originalError: unknown,
): Promise<'resolved' | 'failed'> {
const { resolved, remainingConflicts } = await resolvePackageLockConflicts();

if (resolved > 0 && remainingConflicts.length === 0) {
// MERGE_HEAD exists; git prepared a commit message. --no-edit reuses it.
await execCommand('git commit --no-edit');
console.log(
`Auto-resolved ${resolved} package-lock.json conflict(s) in ${context} by deleting unused lock files. Continuing.`,
);
return 'resolved';
}

if (resolved > 0) {
console.error(
`Auto-resolved package-lock.json conflicts in ${context}, but other merge conflicts remain:\n ${remainingConflicts.join('\n ')}`,
);
} else if (remainingConflicts.length > 0) {
// No package-lock.json conflicts resolved — other conflicts exist
console.error(
`Merge conflicts in ${context}:\n ${remainingConflicts.join('\n ')}\n\nOriginal error: ${originalError}`,
);
} else {
// No conflict lines at all — error was something other than a merge conflict
console.error(`Error in ${context}: ${originalError}`);
}
return 'failed';
}

(async () => {
// Make sure there are not working changes as this will not work with working changes
if (await checkForWorkingChanges()) return 1;
Expand All @@ -29,8 +71,8 @@ import { ExtensionInfo, getExtensions } from '../webpack/webpack.util';
`git merge ${MULTI_TEMPLATE_NAME}/${MULTI_TEMPLATE_BRANCH} --allow-unrelated-histories`,
);
} catch (e) {
console.error(`Error merging from ${MULTI_TEMPLATE_NAME}: ${e}`);
return 1;
if ((await handleConflicts('extensions root', e)) === 'failed') return 1;
// Fall through — do not return 1
}

// Fetch latest on SINGLE_TEMPLATE_REMOTE_NAME to make sure we're up to date
Expand All @@ -48,11 +90,12 @@ import { ExtensionInfo, getExtensions } from '../webpack/webpack.util';
const extensionsBasedOnTemplate: ExtensionInfo[] = [];

// Merge changes from SINGLE_TEMPLATE_REMOTE_NAME into each extension one at a time
// We intend to run these one at a time, so for/of works well here
// Subtree pulls must run one at a time: a merge conflict on one subtree leaves the repo in a
// conflicted state that blocks any further subtree work until it is resolved or aborted.
// eslint-disable-next-line no-restricted-syntax
for (const ext of extensions) {
try {
// We intend to run these one at a time, so awaiting inside the loop works well here
// Intentional sequential await — one subtree pull must finish before the next begins
// eslint-disable-next-line no-await-in-loop
await execCommand(
`git subtree pull --prefix ${ext.dirPathOSIndependent} ${SINGLE_TEMPLATE_NAME} ${SINGLE_TEMPLATE_BRANCH} --squash`,
Expand All @@ -75,20 +118,23 @@ import { ExtensionInfo, getExtensions } from '../webpack/webpack.util';
`${ext.dirName} was never added as a subtree of ${SINGLE_TEMPLATE_NAME}. Feel free to ignore this if this folder is not supposed to be based on ${SINGLE_TEMPLATE_NAME}.\nIf this folder is supposed to be based on ${SINGLE_TEMPLATE_NAME}, move the folder elsewhere, run \`npm run create-extension -- ${ext.dirName}\`, drop the folder back in, and evaluate all working changes before committing.\n`,
);
else {
console.error(`Error pulling from ${SINGLE_TEMPLATE_NAME} to ${ext.dirName}: ${e}`);
// You can only fix merge conflicts on one subtree at a time, so stop
// if we hit an error like merge conflicts
return 1;
// Awaiting inside the loop is intentional — one subtree at a time
// eslint-disable-next-line no-await-in-loop
if ((await handleConflicts(ext.dirName, e)) === 'failed') return 1;
extensionsBasedOnTemplate.push(ext);
}
}
}

// Now that pulling all subtrees is finished and we can have working changes, format all the
// SINGLE_TEMPLATE_REMOTE_NAME-based extension folders to make sure they work properly as subfolders of this
// repo
await Promise.all(
extensionsBasedOnTemplate.map((ext) => formatExtensionFolder(ext.dirPathOSIndependent)),
);
// repo. Also format the repo root (e.g. delete unused lock files) in the same pass.
await Promise.all([
formatExtensionsRoot(),
...extensionsBasedOnTemplate.map((ext) => formatExtensionFolder(ext.dirPathOSIndependent)),
]);

// Check for working changes to see if formatting the extensions changed anything
// Don't commit for them so they know what is going on
Expand Down
Loading
Loading