feat: add support for excluding symlinks and fix bug with commit not found (#2770)

This commit is contained in:
Tonye Jack
2026-01-19 23:51:22 -07:00
committed by GitHub
parent 2f2f6cf099
commit 8c4da285a3
15 changed files with 802 additions and 22 deletions

View File

@@ -275,6 +275,113 @@ jobs:
shell:
bash
test-skip-same-base-and-commit-sha:
name: Test changed-files skip same base and commit sha
runs-on: ubuntu-latest
needs: build
if: needs.build.outputs.files_changed != 'true'
permissions:
contents: read
steps:
- name: Checkout branch
uses: actions/checkout@8e8c483db84b4bee98b60c0593521ed34d9990e8 # v6.0.1
with:
repository: ${{ github.event.pull_request.head.repo.full_name }}
fetch-depth: 0
- name: Download build assets
uses: actions/download-artifact@37930b1c2abaa49bbe596cd826c3c89aef350131 # v7.0.0
with:
name: build-assets
- name: Get head SHA
id: head-sha
run: |
echo "sha=$(git rev-parse HEAD)" >> "$GITHUB_OUTPUT"
shell:
bash
- name: Run changed-files with same base and commit sha
id: changed-files
uses: ./
with:
base_sha: ${{ steps.head-sha.outputs.sha }}
sha: ${{ steps.head-sha.outputs.sha }}
skip_same_sha: true
- name: Verify empty outputs
if: steps.changed-files.outputs.all_changed_files_count != '0' || steps.changed-files.outputs.any_changed != 'false'
run: |
echo "Expected empty outputs; got count=${{ steps.changed-files.outputs.all_changed_files_count }} any_changed=${{ steps.changed-files.outputs.any_changed }}"
exit 1
shell:
bash
- name: Show output
run: |
echo '${{ toJSON(steps.changed-files.outputs) }}'
shell:
bash
test-exclude-symlinks:
name: Test changed-files exclude symlinks
runs-on: ubuntu-latest
needs: build
if: needs.build.outputs.files_changed != 'true'
permissions:
contents: read
steps:
- name: Checkout branch
uses: actions/checkout@8e8c483db84b4bee98b60c0593521ed34d9990e8 # v6.0.1
with:
repository: ${{ github.event.pull_request.head.repo.full_name }}
fetch-depth: 0
- name: Download build assets
uses: actions/download-artifact@37930b1c2abaa49bbe596cd826c3c89aef350131 # v7.0.0
with:
name: build-assets
- name: Run changed-files without symlink exclusion
id: changed-files-symlink-included
uses: ./
with:
base_sha: 955f38aa4f30f1ec92d08049b26e6d736ffea013
sha: ddde96a55848a649c09322c7094b22ef4b4abe23
- name: Verify symlink is present
if: "!contains(steps.changed-files-symlink-included.outputs.added_files, 'test/symlink-to-target')"
run: |
echo "Expected symlink to be present in added_files; got ${{ steps.changed-files-symlink-included.outputs.added_files }}"
exit 1
shell:
bash
- name: Run changed-files excluding symlinks
id: changed-files-symlink-excluded
uses: ./
with:
base_sha: 955f38aa4f30f1ec92d08049b26e6d736ffea013
sha: ddde96a55848a649c09322c7094b22ef4b4abe23
exclude_symlinks: true
- name: Verify symlink is excluded
if: "contains(steps.changed-files-symlink-excluded.outputs.added_files, 'test/symlink-to-target') || contains(steps.changed-files-symlink-excluded.outputs.all_changed_files, 'test/symlink-to-target')"
run: |
echo "Expected symlink to be excluded; got added=${{ steps.changed-files-symlink-excluded.outputs.added_files }} all=${{ steps.changed-files-symlink-excluded.outputs.all_changed_files }}"
exit 1
shell:
bash
- name: Show output
run: |
echo '${{ toJSON(steps.changed-files-symlink-included.outputs) }}'
echo '${{ toJSON(steps.changed-files-symlink-excluded.outputs) }}'
shell:
bash
test-using-branch-names-for-base-sha-and-sha-inputs:
name: Test using branch names for base_sha and sha inputs
runs-on: ubuntu-latest

View File

@@ -412,6 +412,16 @@ Support this project with a :star:
# Default: "false"
exclude_submodules: ''
# Exclude symlinks from changed files.
# Type: boolean
# Default: "false"
exclude_symlinks: ''
# Do not fail when base and head SHAs are identical.
# Type: boolean
# Default: "false"
skip_same_sha: ''
# Fail when the initial diff
# fails.
# Type: boolean

View File

@@ -227,6 +227,14 @@ inputs:
description: "Exclude changes to submodules."
required: false
default: "false"
exclude_symlinks:
description: "Exclude symlinks from changed files."
required: false
default: "false"
skip_same_sha:
description: "Do not fail when base and head SHAs are identical."
required: false
default: "false"
fetch_missing_history_max_retries:
description: "Maximum number of retries to fetch missing history."
required: false

296
dist/index.js generated vendored
View File

@@ -43,7 +43,7 @@ var __importDefault = (this && this.__importDefault) || function (mod) {
return (mod && mod.__esModule) ? mod : { "default": mod };
};
Object.defineProperty(exports, "__esModule", ({ value: true }));
exports.getChangedFilesFromGithubAPI = exports.getAllChangeTypeFiles = exports.getChangeTypeFiles = exports.getAllDiffFiles = exports.ChangeTypeEnum = exports.getRenamedFiles = exports.processChangedFiles = void 0;
exports.getChangedFilesFromGithubAPI = exports.getAllChangeTypeFiles = exports.getChangeTypeFiles = exports.filterSymlinksFromChangedFiles = exports.getAllDiffFiles = exports.ChangeTypeEnum = exports.getRenamedFiles = exports.processChangedFiles = void 0;
const core = __importStar(__nccwpck_require__(7484));
const github = __importStar(__nccwpck_require__(3228));
const flatten_1 = __importDefault(__nccwpck_require__(7047));
@@ -190,7 +190,7 @@ var ChangeTypeEnum;
ChangeTypeEnum["Unmerged"] = "U";
ChangeTypeEnum["Unknown"] = "X";
})(ChangeTypeEnum || (exports.ChangeTypeEnum = ChangeTypeEnum = {}));
const getAllDiffFiles = async ({ workingDirectory, diffSubmodule, diffResult, submodulePaths, outputRenamedFilesAsDeletedAndAdded, fetchAdditionalSubmoduleHistory, failOnInitialDiffError, failOnSubmoduleDiffError }) => {
const getAllDiffFiles = async ({ workingDirectory, diffSubmodule, diffResult, submodulePaths, outputRenamedFilesAsDeletedAndAdded, fetchAdditionalSubmoduleHistory, failOnInitialDiffError, failOnSubmoduleDiffError, submoduleShas }) => {
const files = await (0, utils_1.getAllChangedFiles)({
cwd: workingDirectory,
sha1: diffResult.previousSha,
@@ -210,6 +210,9 @@ const getAllDiffFiles = async ({ workingDirectory, diffSubmodule, diffResult, su
});
const submoduleWorkingDirectory = path.join(workingDirectory, submodulePath);
if (submoduleShaResult.currentSha && submoduleShaResult.previousSha) {
if (submoduleShas) {
submoduleShas[submodulePath] = submoduleShaResult;
}
let diff = '...';
if (!(await (0, utils_1.canDiffCommits)({
cwd: submoduleWorkingDirectory,
@@ -246,6 +249,96 @@ const getAllDiffFiles = async ({ workingDirectory, diffSubmodule, diffResult, su
return files;
};
exports.getAllDiffFiles = getAllDiffFiles;
const filterSymlinksFromChangedFiles = async ({ changedFiles, workingDirectory, diffResult, submodulePaths, submoduleShas }) => {
const filtered = {
[ChangeTypeEnum.Added]: [],
[ChangeTypeEnum.Copied]: [],
[ChangeTypeEnum.Deleted]: [],
[ChangeTypeEnum.Modified]: [],
[ChangeTypeEnum.Renamed]: [],
[ChangeTypeEnum.TypeChanged]: [],
[ChangeTypeEnum.Unmerged]: [],
[ChangeTypeEnum.Unknown]: []
};
const cache = new Map();
const diskCache = new Map();
const getSubmoduleContext = (filePath) => {
const submodulePath = submodulePaths.find(p => filePath.startsWith(`${p}${path.sep}`));
if (!submodulePath) {
return {
cwd: workingDirectory,
relativePath: filePath,
currentSha: diffResult.currentSha,
previousSha: diffResult.previousSha,
isSubmoduleRoot: false
};
}
if (filePath === submodulePath) {
return {
cwd: workingDirectory,
relativePath: filePath,
currentSha: diffResult.currentSha,
previousSha: diffResult.previousSha,
isSubmoduleRoot: true
};
}
const submoduleWorkingDirectory = path.join(workingDirectory, submodulePath);
const relativePath = filePath.substring(submodulePath.length + 1);
const submoduleSha = submoduleShas === null || submoduleShas === void 0 ? void 0 : submoduleShas[submodulePath];
return {
cwd: submoduleWorkingDirectory,
relativePath,
currentSha: (submoduleSha === null || submoduleSha === void 0 ? void 0 : submoduleSha.currentSha) || diffResult.currentSha,
previousSha: (submoduleSha === null || submoduleSha === void 0 ? void 0 : submoduleSha.previousSha) || diffResult.previousSha,
isSubmoduleRoot: false
};
};
const isSymlinkCached = async ({ cwd, filePath, sha, preferDisk }) => {
if (preferDisk) {
const diskKey = `${cwd}|disk|${filePath}`;
const cachedDisk = diskCache.get(diskKey);
if (cachedDisk !== undefined) {
return cachedDisk;
}
const diskResult = await (0, utils_1.isSymlinkOnDisk)({ cwd, filePath });
diskCache.set(diskKey, diskResult);
if (diskResult) {
return true;
}
}
const treeKey = `${cwd}|${sha}|${filePath}`;
const cachedTree = cache.get(treeKey);
if (cachedTree !== undefined) {
return cachedTree;
}
const treeResult = await (0, utils_1.isSymlinkInGitTree)({ cwd, sha, filePath });
cache.set(treeKey, treeResult);
return treeResult;
};
for (const changeType of Object.keys(changedFiles)) {
const files = changedFiles[changeType] || [];
for (const filePath of files) {
const context = getSubmoduleContext(filePath);
if (context.isSubmoduleRoot) {
filtered[changeType].push(filePath);
continue;
}
const isDeleted = changeType === ChangeTypeEnum.Deleted;
const sha = isDeleted ? context.previousSha : context.currentSha;
const isSymlink = await isSymlinkCached({
cwd: context.cwd,
filePath: context.relativePath,
sha,
preferDisk: !isDeleted
});
if (!isSymlink) {
filtered[changeType].push(filePath);
}
}
}
return filtered;
};
exports.filterSymlinksFromChangedFiles = filterSymlinksFromChangedFiles;
function* getFilePaths({ inputs, filePaths, dirNamesIncludeFilePatterns }) {
for (const filePath of filePaths) {
if (inputs.dirNames) {
@@ -1007,6 +1100,17 @@ const getSHAForNonPullRequestEvent = async ({ inputs, env, workingDirectory, isS
}
if (inputs.baseSha && inputs.sha && currentBranch && targetBranch) {
if (previousSha === currentSha) {
if (inputs.skipSameSha) {
core.info(`Skipping diff because previous sha ${previousSha} is equivalent to the current sha ${currentSha}.`);
return {
previousSha,
currentSha,
currentBranch,
targetBranch,
diff,
sameSha: true
};
}
core.error(`Similar commit hashes detected: previous sha: ${previousSha} is equivalent to the current sha: ${currentSha}.`);
core.error(`Please verify that both commits are valid, and increase the fetch_depth to a number higher than ${inputs.fetchDepth}.`);
throw new Error('Similar commit hashes detected.');
@@ -1095,6 +1199,17 @@ const getSHAForNonPullRequestEvent = async ({ inputs, env, workingDirectory, isS
core.debug(`Target branch: ${targetBranch}`);
core.debug(`Current branch: ${currentBranch}`);
if (!initialCommit && previousSha === currentSha) {
if (inputs.skipSameSha) {
core.info(`Skipping diff because previous sha ${previousSha} is equivalent to the current sha ${currentSha}.`);
return {
previousSha,
currentSha,
currentBranch,
targetBranch,
diff,
sameSha: true
};
}
core.error(`Similar commit hashes detected: previous sha: ${previousSha} is equivalent to the current sha: ${currentSha}.`);
core.error(`Please verify that both commits are valid, and increase the fetch_depth to a number higher than ${inputs.fetchDepth}.`);
throw new Error('Similar commit hashes detected.');
@@ -1193,6 +1308,17 @@ const getSHAForPullRequestEvent = async ({ inputs, workingDirectory, isShallow,
let diff = '...';
if (inputs.baseSha && inputs.sha && currentBranch && targetBranch) {
if (previousSha === currentSha) {
if (inputs.skipSameSha) {
core.info(`Skipping diff because previous sha ${previousSha} is equivalent to the current sha ${currentSha}.`);
return {
previousSha,
currentSha,
currentBranch,
targetBranch,
diff,
sameSha: true
};
}
core.error(`Similar commit hashes detected: previous sha: ${previousSha} is equivalent to the current sha: ${currentSha}.`);
core.error(`Please verify that both commits are valid, and increase the fetch_depth to a number higher than ${inputs.fetchDepth}.`);
throw new Error('Similar commit hashes detected.');
@@ -1314,6 +1440,17 @@ const getSHAForPullRequestEvent = async ({ inputs, workingDirectory, isShallow,
throw new Error(`Unable to determine a difference between ${previousSha}${diff}${currentSha}`);
}
if (previousSha === currentSha) {
if (inputs.skipSameSha) {
core.info(`Skipping diff because previous sha ${previousSha} is equivalent to the current sha ${currentSha}.`);
return {
previousSha,
currentSha,
currentBranch,
targetBranch,
diff,
sameSha: true
};
}
core.error(`Similar commit hashes detected: previous sha: ${previousSha} is equivalent to the current sha: ${currentSha}.`);
// This occurs if a PR is created from a forked repository and the event is pull_request_target.
// - name: Checkout to branch
@@ -1376,6 +1513,8 @@ exports.DEFAULT_VALUES_OF_UNSUPPORTED_API_INPUTS = {
fetchAdditionalSubmoduleHistory: false,
dirNamesDeletedFilesIncludeOnlyDeletedDirs: false,
excludeSubmodules: false,
excludeSymlinks: false,
skipSameSha: false,
fetchMissingHistoryMaxRetries: 20,
usePosixPathSeparator: false,
tagsPattern: '*',
@@ -1573,6 +1712,12 @@ const getInputs = () => {
const excludeSubmodules = core.getBooleanInput('exclude_submodules', {
required: false
});
const excludeSymlinks = core.getBooleanInput('exclude_symlinks', {
required: false
});
const skipSameSha = core.getBooleanInput('skip_same_sha', {
required: false
});
const fetchMissingHistoryMaxRetries = core.getInput('fetch_missing_history_max_retries', { required: false });
const usePosixPathSeparator = core.getBooleanInput('use_posix_path_separator', {
required: false
@@ -1625,6 +1770,8 @@ const getInputs = () => {
fetchAdditionalSubmoduleHistory,
dirNamesDeletedFilesIncludeOnlyDeletedDirs,
excludeSubmodules,
excludeSymlinks,
skipSameSha,
usePosixPathSeparator,
tagsPattern,
tagsIgnorePattern,
@@ -1782,8 +1929,49 @@ const getChangedFilesFromLocalGitHistory = async ({ inputs, env, workingDirector
core.endGroup();
return;
}
if (diffResult.sameSha) {
core.info('Base and head SHAs are identical; no changed files to report.');
const emptyChangedFiles = {
[changedFiles_1.ChangeTypeEnum.Added]: [],
[changedFiles_1.ChangeTypeEnum.Copied]: [],
[changedFiles_1.ChangeTypeEnum.Deleted]: [],
[changedFiles_1.ChangeTypeEnum.Modified]: [],
[changedFiles_1.ChangeTypeEnum.Renamed]: [],
[changedFiles_1.ChangeTypeEnum.TypeChanged]: [],
[changedFiles_1.ChangeTypeEnum.Unmerged]: [],
[changedFiles_1.ChangeTypeEnum.Unknown]: []
};
await (0, changedFiles_1.processChangedFiles)({
filePatterns,
allDiffFiles: emptyChangedFiles,
inputs,
yamlFilePatterns,
workingDirectory
});
if (inputs.includeAllOldNewRenamedFiles) {
await (0, utils_1.setOutput)({
key: 'all_old_new_renamed_files',
value: inputs.json ? [] : '',
writeOutputFiles: inputs.writeOutputFiles,
outputDir: inputs.outputDir,
json: inputs.json,
safeOutput: inputs.safeOutput
});
await (0, utils_1.setOutput)({
key: 'all_old_new_renamed_files_count',
value: '0',
writeOutputFiles: inputs.writeOutputFiles,
outputDir: inputs.outputDir,
json: inputs.json
});
}
core.info('All Done!');
core.endGroup();
return;
}
core.info(`Retrieving changes between ${diffResult.previousSha} (${diffResult.targetBranch}) → ${diffResult.currentSha} (${diffResult.currentBranch})`);
const allDiffFiles = await (0, changedFiles_1.getAllDiffFiles)({
const submoduleShas = {};
let allDiffFiles = await (0, changedFiles_1.getAllDiffFiles)({
workingDirectory,
diffSubmodule,
diffResult,
@@ -1791,8 +1979,19 @@ const getChangedFilesFromLocalGitHistory = async ({ inputs, env, workingDirector
outputRenamedFilesAsDeletedAndAdded,
fetchAdditionalSubmoduleHistory: inputs.fetchAdditionalSubmoduleHistory,
failOnInitialDiffError: inputs.failOnInitialDiffError,
failOnSubmoduleDiffError: inputs.failOnSubmoduleDiffError
failOnSubmoduleDiffError: inputs.failOnSubmoduleDiffError,
submoduleShas
});
if (inputs.excludeSymlinks) {
core.info('Excluding symlinks from the diff');
allDiffFiles = await (0, changedFiles_1.filterSymlinksFromChangedFiles)({
changedFiles: allDiffFiles,
workingDirectory,
diffResult,
submodulePaths,
submoduleShas
});
}
core.debug(`All diff files: ${JSON.stringify(allDiffFiles)}`);
core.info('All Done!');
core.endGroup();
@@ -1961,7 +2160,7 @@ var __importDefault = (this && this.__importDefault) || function (mod) {
return (mod && mod.__esModule) ? mod : { "default": mod };
};
Object.defineProperty(exports, "__esModule", ({ value: true }));
exports.warnUnsupportedRESTAPIInputs = exports.hasLocalGitDirectory = exports.recoverDeletedFiles = exports.setOutput = exports.setArrayOutput = exports.getOutputKey = exports.getRecoverFilePatterns = exports.getYamlFilePatterns = exports.getFilePatterns = exports.getDirNamesIncludeFilesPattern = exports.jsonOutput = exports.getDirnameMaxDepth = exports.canDiffCommits = exports.getPreviousGitTag = exports.cleanShaInput = exports.verifyCommitSha = exports.getParentSha = exports.getCurrentBranchName = exports.getRemoteBranchHeadSha = exports.isInsideWorkTree = exports.getHeadSha = exports.gitLog = exports.getFilteredChangedFiles = exports.getAllChangedFiles = exports.gitRenamedFiles = exports.gitSubmoduleDiffSHA = exports.getSubmodulePath = exports.gitFetchSubmodules = exports.gitFetch = exports.submoduleExists = exports.isRepoShallow = exports.updateGitGlobalConfig = exports.exists = exports.verifyMinimumGitVersion = exports.getDirname = exports.normalizeSeparators = exports.isWindows = void 0;
exports.warnUnsupportedRESTAPIInputs = exports.hasLocalGitDirectory = exports.recoverDeletedFiles = exports.setOutput = exports.setArrayOutput = exports.getOutputKey = exports.getRecoverFilePatterns = exports.getYamlFilePatterns = exports.getFilePatterns = exports.getDirNamesIncludeFilesPattern = exports.jsonOutput = exports.getDirnameMaxDepth = exports.canDiffCommits = exports.getPreviousGitTag = exports.cleanShaInput = exports.verifyCommitSha = exports.getParentSha = exports.getCurrentBranchName = exports.getRemoteBranchHeadSha = exports.isInsideWorkTree = exports.getHeadSha = exports.gitLog = exports.getFilteredChangedFiles = exports.getAllChangedFiles = exports.gitRenamedFiles = exports.gitSubmoduleDiffSHA = exports.getSubmodulePath = exports.gitFetchSubmodules = exports.gitFetch = exports.submoduleExists = exports.isRepoShallow = exports.updateGitGlobalConfig = exports.isSymlinkInGitTree = exports.isSymlinkOnDisk = exports.exists = exports.verifyMinimumGitVersion = exports.getDirname = exports.normalizeSeparators = exports.isWindows = void 0;
/*global AsyncIterableIterator*/
const core = __importStar(__nccwpck_require__(7484));
const exec = __importStar(__nccwpck_require__(5236));
@@ -2092,6 +2291,49 @@ const exists = async (filePath) => {
}
};
exports.exists = exists;
/**
* Checks if a file is a symlink on disk
* @param cwd - working directory
* @param filePath - path to check
* @returns file is a symlink
*/
const isSymlinkOnDisk = async ({ cwd, filePath }) => {
try {
const stat = await fs_1.promises.lstat(path.join(cwd, filePath));
return stat.isSymbolicLink();
}
catch (_a) {
return false;
}
};
exports.isSymlinkOnDisk = isSymlinkOnDisk;
/**
* Checks if a file is a symlink in a git tree
* @param cwd - working directory
* @param sha - commit sha
* @param filePath - path to check
* @returns file is a symlink
*/
const isSymlinkInGitTree = async ({ cwd, sha, filePath }) => {
if (!sha) {
return false;
}
const { stdout, exitCode } = await exec.getExecOutput('git', ['ls-tree', '-r', sha, '--', filePath], {
cwd,
ignoreReturnCode: true,
silent: !core.isDebug()
});
if (exitCode !== 0) {
return false;
}
const line = stdout.split('\n').find(Boolean);
if (!line) {
return false;
}
const [mode] = line.split(/\s+/);
return mode === '120000';
};
exports.isSymlinkInGitTree = isSymlinkInGitTree;
/**
* Generates lines of a file as an async iterable iterator
* @param filePath - path of file to read
@@ -2517,13 +2759,45 @@ const cleanShaInput = async ({ sha, cwd, token }) => {
});
if (exitCode !== 0) {
const octokit = github.getOctokit(token);
// If it's not a valid commit sha, assume it's a branch name and get the HEAD sha
const { data: refData } = await octokit.rest.git.getRef({
owner: github.context.repo.owner,
repo: github.context.repo.repo,
ref: `heads/${sha}`
const owner = github.context.repo.owner;
const repo = github.context.repo.repo;
const isNotFoundError = (error) => typeof error === 'object' &&
error !== null &&
'status' in error &&
error.status === 404;
// If it's not a valid commit sha, assume it's a ref name first.
try {
const { data: refData } = await octokit.rest.git.getRef({
owner,
repo,
ref: `heads/${sha}`
});
return refData.object.sha;
}
catch (error) {
if (!isNotFoundError(error)) {
throw error;
}
}
try {
const { data: refData } = await octokit.rest.git.getRef({
owner,
repo,
ref: `tags/${sha}`
});
return refData.object.sha;
}
catch (error) {
if (!isNotFoundError(error)) {
throw error;
}
}
const { data: commitData } = await octokit.rest.git.getCommit({
owner,
repo,
commit_sha: sha
});
return refData.object.sha;
return commitData.sha;
}
return stdout.trim();
};

2
dist/index.js.map generated vendored

File diff suppressed because one or more lines are too long

View File

@@ -12,6 +12,7 @@ exports[`getInputs should correctly parse boolean inputs 1`] = `
"dirNamesIncludeFilesSeparator": "",
"escapeJson": false,
"excludeSubmodules": "false",
"excludeSymlinks": "false",
"failOnInitialDiffError": "false",
"failOnSubmoduleDiffError": "false",
"fetchAdditionalSubmoduleHistory": "false",
@@ -53,6 +54,7 @@ exports[`getInputs should correctly parse boolean inputs 1`] = `
"since": "",
"sinceLastRemoteCommit": "false",
"skipInitialFetch": "true",
"skipSameSha": "false",
"tagsIgnorePattern": "",
"tagsPattern": "*",
"token": "",
@@ -76,6 +78,7 @@ exports[`getInputs should correctly parse numeric inputs 1`] = `
"dirNamesMaxDepth": 2,
"escapeJson": false,
"excludeSubmodules": false,
"excludeSymlinks": false,
"failOnInitialDiffError": false,
"failOnSubmoduleDiffError": false,
"fetchAdditionalSubmoduleHistory": false,
@@ -115,6 +118,7 @@ exports[`getInputs should correctly parse numeric inputs 1`] = `
"since": "",
"sinceLastRemoteCommit": false,
"skipInitialFetch": false,
"skipSameSha": false,
"tagsIgnorePattern": "",
"tagsPattern": "",
"token": "",
@@ -137,6 +141,7 @@ exports[`getInputs should correctly parse string inputs 1`] = `
"dirNamesIncludeFilesSeparator": "",
"escapeJson": false,
"excludeSubmodules": false,
"excludeSymlinks": false,
"failOnInitialDiffError": false,
"failOnSubmoduleDiffError": false,
"fetchAdditionalSubmoduleHistory": false,
@@ -175,6 +180,7 @@ exports[`getInputs should correctly parse string inputs 1`] = `
"since": "",
"sinceLastRemoteCommit": false,
"skipInitialFetch": false,
"skipSameSha": false,
"tagsIgnorePattern": "",
"tagsPattern": "",
"token": "token",
@@ -198,6 +204,7 @@ exports[`getInputs should handle invalid numeric inputs correctly 1`] = `
"dirNamesMaxDepth": 2,
"escapeJson": false,
"excludeSubmodules": false,
"excludeSymlinks": false,
"failOnInitialDiffError": false,
"failOnSubmoduleDiffError": false,
"fetchAdditionalSubmoduleHistory": false,
@@ -237,6 +244,7 @@ exports[`getInputs should handle invalid numeric inputs correctly 1`] = `
"since": "",
"sinceLastRemoteCommit": false,
"skipInitialFetch": false,
"skipSameSha": false,
"tagsIgnorePattern": "",
"tagsPattern": "",
"token": "",
@@ -260,6 +268,7 @@ exports[`getInputs should handle negative numeric inputs correctly 1`] = `
"dirNamesMaxDepth": -2,
"escapeJson": false,
"excludeSubmodules": false,
"excludeSymlinks": false,
"failOnInitialDiffError": false,
"failOnSubmoduleDiffError": false,
"fetchAdditionalSubmoduleHistory": false,
@@ -299,6 +308,7 @@ exports[`getInputs should handle negative numeric inputs correctly 1`] = `
"since": "",
"sinceLastRemoteCommit": false,
"skipInitialFetch": false,
"skipSameSha": false,
"tagsIgnorePattern": "",
"tagsPattern": "",
"token": "",
@@ -321,6 +331,7 @@ exports[`getInputs should return default values when no inputs are provided 1`]
"dirNamesIncludeFilesSeparator": "",
"escapeJson": false,
"excludeSubmodules": false,
"excludeSymlinks": false,
"failOnInitialDiffError": false,
"failOnSubmoduleDiffError": false,
"fetchAdditionalSubmoduleHistory": false,
@@ -362,6 +373,7 @@ exports[`getInputs should return default values when no inputs are provided 1`]
"since": "",
"sinceLastRemoteCommit": false,
"skipInitialFetch": false,
"skipSameSha": false,
"tagsIgnorePattern": "",
"tagsPattern": "*",
"token": "",

View File

@@ -639,6 +639,8 @@ describe('utils test', () => {
negationPatternsFirst: false,
useRestApi: false,
excludeSubmodules: false,
excludeSymlinks: false,
skipSameSha: false,
fetchMissingHistoryMaxRetries: 20,
usePosixPathSeparator: false,
tagsPattern: '*',

View File

@@ -16,6 +16,8 @@ import {
getFilteredChangedFiles,
gitRenamedFiles,
gitSubmoduleDiffSHA,
isSymlinkInGitTree,
isSymlinkOnDisk,
isWindows,
jsonOutput,
setArrayOutput
@@ -220,7 +222,8 @@ export const getAllDiffFiles = async ({
outputRenamedFilesAsDeletedAndAdded,
fetchAdditionalSubmoduleHistory,
failOnInitialDiffError,
failOnSubmoduleDiffError
failOnSubmoduleDiffError,
submoduleShas
}: {
workingDirectory: string
diffSubmodule: boolean
@@ -230,6 +233,7 @@ export const getAllDiffFiles = async ({
fetchAdditionalSubmoduleHistory: boolean
failOnInitialDiffError: boolean
failOnSubmoduleDiffError: boolean
submoduleShas?: Record<string, {previousSha?: string; currentSha?: string}>
}): Promise<ChangedFiles> => {
const files = await getAllChangedFiles({
cwd: workingDirectory,
@@ -256,6 +260,9 @@ export const getAllDiffFiles = async ({
)
if (submoduleShaResult.currentSha && submoduleShaResult.previousSha) {
if (submoduleShas) {
submoduleShas[submodulePath] = submoduleShaResult
}
let diff = '...'
if (
@@ -300,6 +307,139 @@ export const getAllDiffFiles = async ({
return files
}
export const filterSymlinksFromChangedFiles = async ({
changedFiles,
workingDirectory,
diffResult,
submodulePaths,
submoduleShas
}: {
changedFiles: ChangedFiles
workingDirectory: string
diffResult: DiffResult
submodulePaths: string[]
submoduleShas?: Record<string, {previousSha?: string; currentSha?: string}>
}): Promise<ChangedFiles> => {
const filtered: ChangedFiles = {
[ChangeTypeEnum.Added]: [],
[ChangeTypeEnum.Copied]: [],
[ChangeTypeEnum.Deleted]: [],
[ChangeTypeEnum.Modified]: [],
[ChangeTypeEnum.Renamed]: [],
[ChangeTypeEnum.TypeChanged]: [],
[ChangeTypeEnum.Unmerged]: [],
[ChangeTypeEnum.Unknown]: []
}
const cache = new Map<string, boolean>()
const diskCache = new Map<string, boolean>()
const getSubmoduleContext = (
filePath: string
): {
cwd: string
relativePath: string
currentSha: string
previousSha: string
isSubmoduleRoot: boolean
} => {
const submodulePath = submodulePaths.find(p =>
filePath.startsWith(`${p}${path.sep}`)
)
if (!submodulePath) {
return {
cwd: workingDirectory,
relativePath: filePath,
currentSha: diffResult.currentSha,
previousSha: diffResult.previousSha,
isSubmoduleRoot: false
}
}
if (filePath === submodulePath) {
return {
cwd: workingDirectory,
relativePath: filePath,
currentSha: diffResult.currentSha,
previousSha: diffResult.previousSha,
isSubmoduleRoot: true
}
}
const submoduleWorkingDirectory = path.join(workingDirectory, submodulePath)
const relativePath = filePath.substring(submodulePath.length + 1)
const submoduleSha = submoduleShas?.[submodulePath]
return {
cwd: submoduleWorkingDirectory,
relativePath,
currentSha: submoduleSha?.currentSha || diffResult.currentSha,
previousSha: submoduleSha?.previousSha || diffResult.previousSha,
isSubmoduleRoot: false
}
}
const isSymlinkCached = async ({
cwd,
filePath,
sha,
preferDisk
}: {
cwd: string
filePath: string
sha: string
preferDisk: boolean
}): Promise<boolean> => {
if (preferDisk) {
const diskKey = `${cwd}|disk|${filePath}`
const cachedDisk = diskCache.get(diskKey)
if (cachedDisk !== undefined) {
return cachedDisk
}
const diskResult = await isSymlinkOnDisk({cwd, filePath})
diskCache.set(diskKey, diskResult)
if (diskResult) {
return true
}
}
const treeKey = `${cwd}|${sha}|${filePath}`
const cachedTree = cache.get(treeKey)
if (cachedTree !== undefined) {
return cachedTree
}
const treeResult = await isSymlinkInGitTree({cwd, sha, filePath})
cache.set(treeKey, treeResult)
return treeResult
}
for (const changeType of Object.keys(changedFiles) as ChangeTypeEnum[]) {
const files = changedFiles[changeType] || []
for (const filePath of files) {
const context = getSubmoduleContext(filePath)
if (context.isSubmoduleRoot) {
filtered[changeType].push(filePath)
continue
}
const isDeleted = changeType === ChangeTypeEnum.Deleted
const sha = isDeleted ? context.previousSha : context.currentSha
const isSymlink = await isSymlinkCached({
cwd: context.cwd,
filePath: context.relativePath,
sha,
preferDisk: !isDeleted
})
if (!isSymlink) {
filtered[changeType].push(filePath)
}
}
}
return filtered
}
function* getFilePaths({
inputs,
filePaths,

View File

@@ -84,6 +84,7 @@ export interface DiffResult {
targetBranch: string
diff: string
initialCommit?: boolean
sameSha?: boolean
}
interface SHAForNonPullRequestEvent {
@@ -198,6 +199,19 @@ export const getSHAForNonPullRequestEvent = async ({
if (inputs.baseSha && inputs.sha && currentBranch && targetBranch) {
if (previousSha === currentSha) {
if (inputs.skipSameSha) {
core.info(
`Skipping diff because previous sha ${previousSha} is equivalent to the current sha ${currentSha}.`
)
return {
previousSha,
currentSha,
currentBranch,
targetBranch,
diff,
sameSha: true
}
}
core.error(
`Similar commit hashes detected: previous sha: ${previousSha} is equivalent to the current sha: ${currentSha}.`
)
@@ -305,6 +319,19 @@ export const getSHAForNonPullRequestEvent = async ({
core.debug(`Current branch: ${currentBranch}`)
if (!initialCommit && previousSha === currentSha) {
if (inputs.skipSameSha) {
core.info(
`Skipping diff because previous sha ${previousSha} is equivalent to the current sha ${currentSha}.`
)
return {
previousSha,
currentSha,
currentBranch,
targetBranch,
diff,
sameSha: true
}
}
core.error(
`Similar commit hashes detected: previous sha: ${previousSha} is equivalent to the current sha: ${currentSha}.`
)
@@ -430,6 +457,19 @@ export const getSHAForPullRequestEvent = async ({
if (inputs.baseSha && inputs.sha && currentBranch && targetBranch) {
if (previousSha === currentSha) {
if (inputs.skipSameSha) {
core.info(
`Skipping diff because previous sha ${previousSha} is equivalent to the current sha ${currentSha}.`
)
return {
previousSha,
currentSha,
currentBranch,
targetBranch,
diff,
sameSha: true
}
}
core.error(
`Similar commit hashes detected: previous sha: ${previousSha} is equivalent to the current sha: ${currentSha}.`
)
@@ -608,6 +648,19 @@ export const getSHAForPullRequestEvent = async ({
}
if (previousSha === currentSha) {
if (inputs.skipSameSha) {
core.info(
`Skipping diff because previous sha ${previousSha} is equivalent to the current sha ${currentSha}.`
)
return {
previousSha,
currentSha,
currentBranch,
targetBranch,
diff,
sameSha: true
}
}
core.error(
`Similar commit hashes detected: previous sha: ${previousSha} is equivalent to the current sha: ${currentSha}.`
)

View File

@@ -22,6 +22,8 @@ export const DEFAULT_VALUES_OF_UNSUPPORTED_API_INPUTS: Partial<Inputs> = {
fetchAdditionalSubmoduleHistory: false,
dirNamesDeletedFilesIncludeOnlyDeletedDirs: false,
excludeSubmodules: false,
excludeSymlinks: false,
skipSameSha: false,
fetchMissingHistoryMaxRetries: 20,
usePosixPathSeparator: false,
tagsPattern: '*',

View File

@@ -55,6 +55,8 @@ export type Inputs = {
negationPatternsFirst: boolean
useRestApi: boolean
excludeSubmodules: boolean
excludeSymlinks: boolean
skipSameSha: boolean
fetchMissingHistoryMaxRetries?: number
usePosixPathSeparator: boolean
tagsPattern: string
@@ -249,6 +251,14 @@ export const getInputs = (): Inputs => {
required: false
})
const excludeSymlinks = core.getBooleanInput('exclude_symlinks', {
required: false
})
const skipSameSha = core.getBooleanInput('skip_same_sha', {
required: false
})
const fetchMissingHistoryMaxRetries = core.getInput(
'fetch_missing_history_max_retries',
{required: false}
@@ -310,6 +320,8 @@ export const getInputs = (): Inputs => {
fetchAdditionalSubmoduleHistory,
dirNamesDeletedFilesIncludeOnlyDeletedDirs,
excludeSubmodules,
excludeSymlinks,
skipSameSha,
usePosixPathSeparator,
tagsPattern,
tagsIgnorePattern,

View File

@@ -5,8 +5,10 @@ import {
processChangedFiles,
ChangeTypeEnum,
getAllDiffFiles,
filterSymlinksFromChangedFiles,
getChangedFilesFromGithubAPI,
getRenamedFiles
getRenamedFiles,
ChangedFiles
} from './changedFiles'
import {
DiffResult,
@@ -127,11 +129,57 @@ const getChangedFilesFromLocalGitHistory = async ({
return
}
if (diffResult.sameSha) {
core.info('Base and head SHAs are identical; no changed files to report.')
const emptyChangedFiles: ChangedFiles = {
[ChangeTypeEnum.Added]: [],
[ChangeTypeEnum.Copied]: [],
[ChangeTypeEnum.Deleted]: [],
[ChangeTypeEnum.Modified]: [],
[ChangeTypeEnum.Renamed]: [],
[ChangeTypeEnum.TypeChanged]: [],
[ChangeTypeEnum.Unmerged]: [],
[ChangeTypeEnum.Unknown]: []
}
await processChangedFiles({
filePatterns,
allDiffFiles: emptyChangedFiles,
inputs,
yamlFilePatterns,
workingDirectory
})
if (inputs.includeAllOldNewRenamedFiles) {
await setOutput({
key: 'all_old_new_renamed_files',
value: inputs.json ? [] : '',
writeOutputFiles: inputs.writeOutputFiles,
outputDir: inputs.outputDir,
json: inputs.json,
safeOutput: inputs.safeOutput
})
await setOutput({
key: 'all_old_new_renamed_files_count',
value: '0',
writeOutputFiles: inputs.writeOutputFiles,
outputDir: inputs.outputDir,
json: inputs.json
})
}
core.info('All Done!')
core.endGroup()
return
}
core.info(
`Retrieving changes between ${diffResult.previousSha} (${diffResult.targetBranch}) → ${diffResult.currentSha} (${diffResult.currentBranch})`
)
const allDiffFiles = await getAllDiffFiles({
const submoduleShas: Record<
string,
{previousSha?: string; currentSha?: string}
> = {}
let allDiffFiles = await getAllDiffFiles({
workingDirectory,
diffSubmodule,
diffResult,
@@ -139,8 +187,20 @@ const getChangedFilesFromLocalGitHistory = async ({
outputRenamedFilesAsDeletedAndAdded,
fetchAdditionalSubmoduleHistory: inputs.fetchAdditionalSubmoduleHistory,
failOnInitialDiffError: inputs.failOnInitialDiffError,
failOnSubmoduleDiffError: inputs.failOnSubmoduleDiffError
failOnSubmoduleDiffError: inputs.failOnSubmoduleDiffError,
submoduleShas
})
if (inputs.excludeSymlinks) {
core.info('Excluding symlinks from the diff')
allDiffFiles = await filterSymlinksFromChangedFiles({
changedFiles: allDiffFiles,
workingDirectory,
diffResult,
submodulePaths,
submoduleShas
})
}
core.debug(`All diff files: ${JSON.stringify(allDiffFiles)}`)
core.info('All Done!')
core.endGroup()

View File

@@ -152,6 +152,69 @@ export const exists = async (filePath: string): Promise<boolean> => {
}
}
/**
* Checks if a file is a symlink on disk
* @param cwd - working directory
* @param filePath - path to check
* @returns file is a symlink
*/
export const isSymlinkOnDisk = async ({
cwd,
filePath
}: {
cwd: string
filePath: string
}): Promise<boolean> => {
try {
const stat = await fs.lstat(path.join(cwd, filePath))
return stat.isSymbolicLink()
} catch {
return false
}
}
/**
* Checks if a file is a symlink in a git tree
* @param cwd - working directory
* @param sha - commit sha
* @param filePath - path to check
* @returns file is a symlink
*/
export const isSymlinkInGitTree = async ({
cwd,
sha,
filePath
}: {
cwd: string
sha: string
filePath: string
}): Promise<boolean> => {
if (!sha) {
return false
}
const {stdout, exitCode} = await exec.getExecOutput(
'git',
['ls-tree', '-r', sha, '--', filePath],
{
cwd,
ignoreReturnCode: true,
silent: !core.isDebug()
}
)
if (exitCode !== 0) {
return false
}
const line = stdout.split('\n').find(Boolean)
if (!line) {
return false
}
const [mode] = line.split(/\s+/)
return mode === '120000'
}
/**
* Generates lines of a file as an async iterable iterator
* @param filePath - path of file to read
@@ -819,14 +882,49 @@ export const cleanShaInput = async ({
if (exitCode !== 0) {
const octokit = github.getOctokit(token)
// If it's not a valid commit sha, assume it's a branch name and get the HEAD sha
const {data: refData} = await octokit.rest.git.getRef({
owner: github.context.repo.owner,
repo: github.context.repo.repo,
ref: `heads/${sha}`
const owner = github.context.repo.owner
const repo = github.context.repo.repo
const isNotFoundError = (error: unknown): boolean =>
typeof error === 'object' &&
error !== null &&
'status' in error &&
(error as {status?: number}).status === 404
// If it's not a valid commit sha, assume it's a ref name first.
try {
const {data: refData} = await octokit.rest.git.getRef({
owner,
repo,
ref: `heads/${sha}`
})
return refData.object.sha
} catch (error) {
if (!isNotFoundError(error)) {
throw error
}
}
try {
const {data: refData} = await octokit.rest.git.getRef({
owner,
repo,
ref: `tags/${sha}`
})
return refData.object.sha
} catch (error) {
if (!isNotFoundError(error)) {
throw error
}
}
const {data: commitData} = await octokit.rest.git.getCommit({
owner,
repo,
commit_sha: sha
})
return refData.object.sha
return commitData.sha
}
return stdout.trim()

1
test/symlink-target.txt Normal file
View File

@@ -0,0 +1 @@
Symlink target fixture.

1
test/symlink-to-target Symbolic link
View File

@@ -0,0 +1 @@
symlink-target.txt