-
-
Notifications
You must be signed in to change notification settings - Fork 0
/
Copy pathindex.js
306 lines (275 loc) · 9.01 KB
/
index.js
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
import fs from 'node:fs/promises'
import fss from 'node:fs'
import path from 'node:path'
import * as core from '@actions/core'
import { fdir } from 'fdir'
import { exec } from 'tinyexec'
const REPO_URL = `${process.env.GITHUB_SERVER_URL}/${process.env.GITHUB_REPOSITORY}`
main()
async function main() {
const mapLines = core.getMultilineInput('map', { required: true })
const skipUnchangedCheck = core.getBooleanInput('skip-unchanged-check')
const dryRun = core.getBooleanInput('dry-run')
const ghToken = core.getInput('token')
let previousLineSkipped = false
for (let i = 0; i < mapLines.length; i++) {
const line = mapLines[i]
let [sourceDir, targetBranch] = line.split('->')
sourceDir = sourceDir.trim()
if (!sourceDir) {
core.warning(`Invalid line: "${line}". First parameter is empty`)
continue
}
targetBranch = targetBranch?.trim()
if (!targetBranch) {
core.warning(`Invalid line: "${line}". Second parameter is empty`)
continue
}
if (sourceDir.includes('*')) {
const additionalLinesToInject = expandGlobLine(sourceDir, targetBranch)
mapLines.splice(i + 1, 0, ...additionalLinesToInject)
continue
}
if (skipUnchangedCheck) {
core.debug('Skip unchanged check')
} else if (
// skip if git branch exists and source directory has not changed
!(await isGitBranchExists(targetBranch)) &&
!(await hasGitChanged(sourceDir))
) {
core.info(
yellow(
`Skip sync "${sourceDir}" directory to "${targetBranch}" branch as unchanged`,
),
)
previousLineSkipped = true
continue
}
// Add new line spacing between the last skip and the next sync so it's easier to read
if (previousLineSkipped) {
console.log()
previousLineSkipped = false
}
core.info(blue(`Sync "${sourceDir}" directory to "${targetBranch}" branch`))
await gitForcePush(sourceDir, targetBranch, dryRun, ghToken)
console.log()
}
}
/**
* @param {string} branch
*/
async function isGitBranchExists(branch) {
const result = exec('git', ['show-ref', '--quiet', `refs/heads/${branch}`])
await result
return result.exitCode === 0
}
/**
* @param {string} sourceDir
*/
async function hasGitChanged(sourceDir) {
if (sourceDir[0] === '/') {
sourceDir = sourceDir.slice(1)
}
if (sourceDir === '') {
sourceDir = '.'
}
const result = exec(
'git',
['diff', '--quiet', 'HEAD', 'HEAD~1', '--', sourceDir],
{ nodeOptions: { stdio: ['ignore', 'inherit', 'inherit'] } },
)
await result
return result.exitCode === 1
}
/**
* @param {string} sourceDir
* @param {string} targetBranch
* @param {boolean} dryRun
* @param {string} ghToken
*/
async function gitForcePush(sourceDir, targetBranch, dryRun, ghToken) {
const sourcePath = path.join(process.cwd(), sourceDir)
const o = {
nodeOptions: { stdio: ['ignore', 'inherit', 'inherit'] },
throwOnError: true,
}
core.debug(`Changing directory to "${sourcePath}"`)
const originalCwd = process.cwd()
process.chdir(sourcePath)
const gitDir = path.join(sourcePath, '.git')
const commitMessage = `Sync from ${process.env.GITHUB_SHA}`
try {
// Re-use existing git if available (e.g. root)
if (fss.existsSync(gitDir)) {
core.debug(`Found existing git directory at "${gitDir}"`)
core.debug(`Force pushing to "${targetBranch}" branch`)
if (dryRun) {
core.info(`\
[dry run]
git checkout -d ${targetBranch}
git checkout --orphan ${targetBranch}
git config user.name github-actions[bot]
git config user.email 41898282+github-actions[bot]@users.noreply.github.com
git commit -am "${commitMessage}"
git push -f origin HEAD:${targetBranch}
git checkout ${process.env.GITHUB_REF_NAME}`)
} else {
await x('git', ['checkout', '-d', targetBranch], false)
await x('git', ['checkout', '--orphan', targetBranch])
await x('git', ['config', 'user.name', 'github-actions[bot]'])
// prettier-ignore
await x('git', ['config', 'user.email', '41898282+github-actions[bot]@users.noreply.github.com'])
await x('git', ['commit', '-am', commitMessage])
await x('git', ['push', '-f', 'origin', `HEAD:${targetBranch}`])
await x('git', ['checkout', process.env.GITHUB_REF_NAME])
}
} else {
core.debug(`Initializing git repository at "${sourcePath}"`)
// Custom git init requires own authorization setup (inspired from actions/checkout)
const repoUrl = new URL(REPO_URL)
repoUrl.username = 'x-access-token'
repoUrl.password = ghToken
if (dryRun) {
core.info(`\
[dry run]
git init -b ${targetBranch}
git config user.name github-actions[bot]
git config user.email 41898282+github-actions[bot]@users.noreply.github.com
git add .
git commit -m "${commitMessage}"
git remote add origin ${REPO_URL}
git push -f origin HEAD:${targetBranch}`)
} else {
await x('git', ['init', '-b', targetBranch])
await x('git', ['config', 'user.name', 'github-actions[bot]'])
// prettier-ignore
await x('git', ['config', 'user.email', '41898282+github-actions[bot]@users.noreply.github.com'])
await x('git', ['add', '.'])
await x('git', ['commit', '-m', commitMessage])
await x('git', ['remote', 'add', 'origin', repoUrl])
core.debug(`Force pushing to "${targetBranch}" branch`)
await x('git', ['push', '-f', 'origin', `HEAD:${targetBranch}`])
await fs.rm(gitDir, { recursive: true, force: true })
}
}
} finally {
core.debug(`Changing directory back to "${originalCwd}"`)
process.chdir(originalCwd)
}
}
/**
* @param {string} command
* @param {string[]} args
* @param {boolean} inherit
*/
async function x(command, args, inherit = true) {
// NOTE: while this may log GH_TOKEN, github seems to help auto redact it
core.startGroup(`${command} ${args.join(' ')}`)
try {
await exec(
command,
args,
inherit
? {
nodeOptions: { stdio: ['ignore', 'inherit', 'inherit'] },
throwOnError: true,
}
: undefined,
)
} finally {
core.endGroup()
}
}
/**
* @param {string} sourceDir
* @param {string} targetBranch
*/
function expandGlobLine(sourceDir, targetBranch) {
const regex = createGlobalRegExp(sourceDir)
// Get parent directories before the first * so we can use it to exclude out directories earlier
const sourceParentDirs = getParentDirs(sourceDir, process.cwd())
// Do the globbing!
const result = new fdir()
.withRelativePaths()
.withPathSeparator('/')
.onlyDirs()
.exclude((_, dirPath) => {
return !sourceParentDirs.some((p) => dirPath.startsWith(p))
})
.filter((p, isDir) => {
// NOTE: directory path always have a trailing slash
return isDir && p !== '.' && regex.test('/' + p.replace(/\\/g, '/'))
})
.crawl(process.cwd())
.sync()
const additionalLinesToInject = []
for (const matchedDir of result) {
// Normalize slash
const matchedSourceDir = '/' + matchedDir.replace(/\\/g, '/')
// Get the matched groups value
const matchedGroups = regex.exec(matchedSourceDir)
// Iterate target segment, perform replacement for each segment
// that contains * with the matched group value
const targetBranchSegments = targetBranch.split('/')
let replacementIndex = 1
for (let j = 0; j < targetBranchSegments.length; j++) {
if (targetBranchSegments[j].includes('*')) {
targetBranchSegments[j] = targetBranchSegments[j].replace(
/\*+/g,
() => matchedGroups[replacementIndex++],
)
}
}
// Get new target branch and inject it
const newTargetBranch = targetBranchSegments.join('/')
additionalLinesToInject.push(`${matchedSourceDir} -> ${newTargetBranch}`)
// TODO: detect abnormal mappings (e.g. inbalance or unsufficient *)
}
core.debug(
`Injecting additional mappings:\n${additionalLinesToInject.join('\n')}`,
)
return additionalLinesToInject
}
/**
* @param {string} sourceDir
*/
function createGlobalRegExp(sourceDir) {
// Ensure starting and ending slash to easier match with fdir
if (sourceDir[0] !== '/') sourceDir = '/' + sourceDir
if (sourceDir[sourceDir.length - 1] !== '/') sourceDir += '/'
const replaced = sourceDir
.replace(/\*\*\//g, '(?:(.*)/)?')
.replace(/\*\*/g, '(.*)')
.replace(/\*/g, '([^/]+)')
return new RegExp(`^${replaced}$`)
}
/**
* @param {string} sourceDir
* @param {string} cwd
*/
function getParentDirs(sourceDir, cwd) {
if (sourceDir[0] === '/') {
sourceDir = sourceDir.slice(1)
}
const segments = sourceDir.split('/')
const firstStar = segments.findIndex((s) => s.includes('*'))
segments.splice(firstStar)
/** @type {string[]} */
const parentDirs = []
for (let i = 0; i < segments.length; i++) {
parentDirs.push(path.join(cwd, segments.slice(0, i + 1).join('/'), '/'))
}
return parentDirs
}
/**
* @param {string} str
*/
function blue(str) {
return `\u001b[34m${str}\u001b[0m`
}
/**
* @param {string} str
*/
function yellow(str) {
return `\u001b[33m${str}\u001b[0m`
}