diff --git a/README.md b/README.md index cb835570..c7c0cb3b 100644 --- a/README.md +++ b/README.md @@ -108,6 +108,7 @@ Here are all the inputs [repo-file-sync-action](https://github.com/BetaHuhn/repo | `ASSIGNEES` | People to assign to the pull request | **No** | N/A | | `COMMIT_PREFIX` | Prefix for commit message and pull request title | **No** | 🔄 | | `COMMIT_BODY` | Commit message body. Will be appended to commit message, separated by two line returns. | **No** | '' | +| `ORIGINAL_MESSAGE` | Use original commit message instead. Only works if the file(s) where changed and the action was triggered by pushing a single commit. | **No** | false | | `COMMIT_EACH_FILE` | Commit each file seperately | **No** | true | | `GIT_EMAIL` | The e-mail address used to commit the synced files | **Only when using installation token** | the email of the PAT used | | `GIT_USERNAME` | The username used to commit the synced files | **Only when using installation token** | the username of the PAT used | diff --git a/action.yml b/action.yml index ff6931f2..4d4b2d33 100644 --- a/action.yml +++ b/action.yml @@ -63,6 +63,10 @@ inputs: description: | Overwrite any existing Sync PR with the new changes. Defaults to true required: false + ORIGINAL_MESSAGE: + description: | + Re-use the original commit message for commits. Works only if the action is triggered by pushing one commit. Defaults to false + required: false SKIP_PR: description: | Skips creating a Pull Request and pushes directly to the default branch. Defaults to false diff --git a/src/config.js b/src/config.js index 3d7a3fdf..f4e10b08 100644 --- a/src/config.js +++ b/src/config.js @@ -95,6 +95,11 @@ try { type: 'boolean', default: false }), + ORIGINAL_MESSAGE: getInput({ + key: 'ORIGINAL_MESSAGE', + type: 'boolean', + default: false + }), BRANCH_PREFIX: getInput({ key: 'BRANCH_PREFIX', default: 'repo-sync/SOURCE_REPO_NAME' diff --git a/src/git.js b/src/git.js index 8ac13216..6d620f91 100644 --- a/src/git.js +++ b/src/git.js @@ -1,5 +1,6 @@ const { parse } = require('@putout/git-status-porcelain') const core = require('@actions/core') +const github = require('@actions/github') const { GitHub, getOctokitOptions } = require('@actions/github/lib/utils') const { throttling } = require('@octokit/plugin-throttling') const path = require('path') @@ -127,6 +128,58 @@ class Git { ) } + isOneCommitPush() { + return github.context.eventName === 'push' && github.context.payload.commits.length === 1 + } + + originalCommitMessage() { + return github.context.payload.commits[0].message + } + + parseGitDiffOutput(string) { // parses git diff output and returns a dictionary mapping the file path to the diff output for this file + // split diff into separate entries for separate files. \ndiff --git should be a reliable way to detect the separation, as content of files is always indented + return `\n${ string }`.split('\ndiff --git').slice(1).reduce((resultDict, fileDiff) => { + const lines = fileDiff.split('\n') + const lastHeaderLineIndex = lines.findIndex((line) => line.startsWith('+++')) + const plainDiff = lines.slice(lastHeaderLineIndex + 1).join('\n').trim() + let filePath = '' + if (lines[lastHeaderLineIndex].startsWith('+++ b/')) { // every file except removed files + filePath = lines[lastHeaderLineIndex].slice(6) // remove '+++ b/' + } else { // for removed file need to use header line with filename before deletion + filePath = lines[lastHeaderLineIndex - 1].slice(6) // remove '--- a/' + } + return { ...resultDict, [filePath]: plainDiff } + }, {}) + } + + async getChangesFromLastCommit(source) { // gets array of git diffs for the source, which either can be a file or a dict + if (this.lastCommitChanges === undefined) { + const diff = await this.github.repos.compareCommits({ + mediaType: { + format: 'diff' + }, + owner: github.context.payload.repository.owner.name, + repo: github.context.payload.repository.name, + base: github.context.payload.before, + head: github.context.payload.after + }) + this.lastCommitChanges = this.parseGitDiffOutput(diff.data) + } + if (source.endsWith('/')) { + return Object.keys(this.lastCommitChanges).filter((filePath) => filePath.startsWith(source)).reduce((result, key) => [ ...result, this.lastCommitChanges[key] ], []) + } else { + return this.lastCommitChanges[source] === undefined ? [] : [ this.lastCommitChanges[source] ] + } + } + + async changes(destination) { // gets array of git diffs for the destination, which either can be a file or a dict + const output = await execCmd( + `git diff HEAD ${ destination }`, + this.workingDir + ) + return Object.values(this.parseGitDiffOutput(output)) + } + async hasChanges() { const statusOutput = await execCmd( `git status --porcelain`, @@ -142,7 +195,7 @@ class Git { message += `\n\n${ COMMIT_BODY }` } return execCmd( - `git commit -m "${ message }"`, + `git commit -m "${ message.replace(/"/g, '\\"') }"`, this.workingDir ) } diff --git a/src/helpers.js b/src/helpers.js index ddb81da9..487f64be 100644 --- a/src/helpers.js +++ b/src/helpers.js @@ -98,6 +98,8 @@ const remove = async (src) => { return fs.remove(src) } +const arrayEquals = (array1, array2) => Array.isArray(array1) && Array.isArray(array2) && array1.length === array2.length && array1.every((value, i) => value === array2[i]) + module.exports = { forEach, dedent, @@ -105,5 +107,6 @@ module.exports = { pathIsDirectory, execCmd, copy, - remove + remove, + arrayEquals } \ No newline at end of file diff --git a/src/index.js b/src/index.js index 07a22ef9..2dffa9cf 100644 --- a/src/index.js +++ b/src/index.js @@ -2,7 +2,7 @@ const core = require('@actions/core') const fs = require('fs') const Git = require('./git') -const { forEach, dedent, addTrailingSlash, pathIsDirectory, copy, remove } = require('./helpers') +const { forEach, dedent, addTrailingSlash, pathIsDirectory, copy, remove, arrayEquals } = require('./helpers') const { parseConfig, @@ -14,7 +14,8 @@ const { TMP_DIR, SKIP_CLEANUP, OVERWRITE_EXISTING_PR, - SKIP_PR + SKIP_PR, + ORIGINAL_MESSAGE } = require('./config') const run = async () => { @@ -82,14 +83,15 @@ const run = async () => { // Use different commit/pr message based on if the source is a directory or file const directory = isDirectory ? 'directory' : '' const otherFiles = isDirectory ? 'and copied all sub files/folders' : '' + const useOriginalCommitMessage = ORIGINAL_MESSAGE && git.isOneCommitPush() && arrayEquals(await git.getChangesFromLastCommit(file.source), await git.changes(file.dest)) const message = { true: { - commit: `${ COMMIT_PREFIX } Synced local '${ file.dest }' with remote '${ file.source }'`, + commit: useOriginalCommitMessage ? git.originalCommitMessage() : `${ COMMIT_PREFIX } Synced local '${ file.dest }' with remote '${ file.source }'`, pr: `Synced local ${ directory } ${ file.dest } with remote ${ directory } ${ file.source }` }, false: { - commit: `${ COMMIT_PREFIX } Created local '${ file.dest }' from remote '${ file.source }'`, + commit: useOriginalCommitMessage ? git.originalCommitMessage() : `${ COMMIT_PREFIX } Created local '${ file.dest }' from remote '${ file.source }'`, pr: `Created local ${ directory } ${ file.dest } ${ otherFiles } from remote ${ directory } ${ file.source }` } } @@ -128,7 +130,14 @@ const run = async () => { if (hasChanges === true) { core.debug(`Creating commit for remaining files`) - await git.commit() + let useOriginalCommitMessage = ORIGINAL_MESSAGE && git.isOneCommitPush() + if (useOriginalCommitMessage) { + await forEach(item.files, async (file) => { + useOriginalCommitMessage = useOriginalCommitMessage && arrayEquals(await git.getChangesFromLastCommit(file.source), await git.changes(file.dest)) + }) + } + + await git.commit(useOriginalCommitMessage ? git.originalCommitMessage() : undefined) modified.push({ dest: git.workingDir })