diff --git a/.eslintignore b/.eslintignore index 528dc4e..53f6f01 100644 --- a/.eslintignore +++ b/.eslintignore @@ -1 +1,6 @@ -scripts \ No newline at end of file +.github +dist +scripts +test +node_modules +test/task/workspace-repo/.dist \ No newline at end of file diff --git a/.eslintrc.js b/.eslintrc.js index 500e90d..d6dd7ea 100644 --- a/.eslintrc.js +++ b/.eslintrc.js @@ -5,12 +5,20 @@ module.exports = { ], parser: '@typescript-eslint/parser', rules: { - indent: ['error', 4], - 'no-unused-vars': 'off', - 'no-restricted-syntax': 'off', - 'no-await-in-loop': 'off', 'import/extensions': 'off', 'import/no-unresolved': 'off', + + indent: ['error', 4], + 'no-unused-vars': ['warn'], + 'no-restricted-syntax': [ + 2, + 'WithStatement', + 'LabeledStatement', + 'SwitchCase', + ], + 'no-await-in-loop': 'off', 'class-methods-use-this': 'off', + 'no-continue': 'off', + 'no-shadow': 'off', }, }; diff --git a/.gitignore b/.gitignore index ae0a7b7..5443c1f 100644 --- a/.gitignore +++ b/.gitignore @@ -28,7 +28,7 @@ npm-debug.log* # 忽略测试结果 coverage/ -test/.dist* +test/**/.dist* # 忽略编辑器生成的文件 .vscode/ diff --git a/package.json b/package.json index a72fed3..31e9dfa 100644 --- a/package.json +++ b/package.json @@ -22,6 +22,7 @@ "typescript": "^5.2.2" }, "dependencies": { + "@itharbors/structures": "^1.0.1", "chalk": "^4.1.2" } } diff --git a/source/internal.ts b/source/internal.ts index 1908c0f..e16e1b1 100644 --- a/source/internal.ts +++ b/source/internal.ts @@ -2,15 +2,23 @@ import { join, isAbsolute, dirname, basename, } from 'node:path'; -import { existsSync, mkdirSync, renameSync } from 'node:fs'; -import { yellow, magenta, cyan } from 'chalk'; +import { + existsSync, + mkdirSync, + renameSync, + statSync, +} from 'node:fs'; + +import { yellow, green, italic } from 'chalk'; import { registerTask, Task, TaskState } from './task'; -import { bash, print, printEmpty } from './utils'; +import { bash } from './utils'; const cmd = { git: process.platform === 'win32' ? 'git' : 'git', + tsc: process.platform === 'win32' ? 'tsc.cmd' : 'tsc', + lessc: process.platform === 'win32' ? 'lessc.cmd' : 'lessc', }; export type TscConfig = string[]; @@ -41,36 +49,79 @@ type repoConfigItem = { export type RepoConfig = repoConfigItem[]; class TscTask extends Task { - getName() { - return 'tsc'; - } - getTitle() { return 'Compile with tsc'; } async execute(workspace: string, config: TscConfig): Promise { - let err = false; + let hasError = false; for (const relativePath of config) { // 将相对路径转成绝对路径 const path = isAbsolute(relativePath) ? relativePath : join(workspace, relativePath); + const dataItem = this.getCache(path); + + // 新的缓存数据 + const newDataItem: { + [key: string]: number; + } = {}; + + // 编译的文件是否有变化 + let changed = false; + + // 获取编译的文件列表 try { - // 实际编译 - await bash('tsc', [], { + const fileArray: string[] = []; + await bash('npx', [cmd.tsc, '--listFiles'], { cwd: path, + }, (data) => { + data.toString().split(/\r|\n/).forEach((file) => { + if (existsSync(file)) { + fileArray.push(file); + } + }); + }); + this.print(`${italic(relativePath)} Compile files: ${green(fileArray.length)}`); + + fileArray.forEach((file) => { + const stat = statSync(file); + const mtime = stat.mtime.getTime(); + if (!dataItem[file] || mtime !== dataItem[file]) { + changed = true; + } + newDataItem[file] = mtime; }); } catch (error) { - console.error(error); - err = true; + const err = error as Error; + this.print(err.message); + hasError = true; + } + + // 没有变化 + if (changed === false) { + continue; + } + + // 有变化的时候,更新缓存 + this.setCache(path, newDataItem); + + // 实际编译 + try { + await bash('npx', [cmd.tsc], { + cwd: path, + }); + } catch (error) { + const err = error as Error; + this.print(err.message); + hasError = true; } } - return err ? TaskState.error : TaskState.success; + return hasError ? TaskState.error : TaskState.success; } } -registerTask(TscTask); +registerTask('tsc', TscTask); export const RepoTaskMethods = { /** @@ -106,16 +157,13 @@ export const RepoTaskMethods = { }, }; class RepoTask extends Task { - getName() { - return 'repo'; - } - getTitle() { return 'Synchronize the Git repository'; } async execute(workspace: string, repoConfigArray: RepoConfig): Promise { const err = false; + const task = this; async function checkoutRepo(config: repoConfigItem): Promise { // 仓库绝对地址 @@ -123,19 +171,19 @@ class RepoTask extends Task { const bsd = dirname(path); const bsn = basename(path); - print(yellow(`>> ${config.repo.url}`)); + task.print(yellow(`>> ${config.repo.url}`)); // 允许配置某些仓库跳过 if (config.skip) { - print('Skip checking the current repository due to configuration.'); - print('Please manually confirm if the repository is on the latest branch.'); + task.print('Skip checking the current repository due to configuration.'); + task.print('Please manually confirm if the repository is on the latest branch.'); return TaskState.skip; } // 如果文件夹不是 git 仓库或者不存在,则重新 clone if (existsSync(path)) { if (!existsSync(join(path, '.git'))) { - print('Detecting that the folder is not a valid GIT repository. Backup the folder and attempt to re-clone.'); + task.print('Detecting that the folder is not a valid GIT repository. Backup the folder and attempt to re-clone.'); const dirBackup = join(bsd, `_${bsn}`); renameSync(path, dirBackup); } @@ -167,8 +215,8 @@ class RepoTask extends Task { fetchError = error as Error; } if (fetchError) { - print(`Syncing remote failed [ git fetch ${config.repo.name} ]`); - print(fetchError); + task.print(`Syncing remote failed [ git fetch ${config.repo.name} ]`); + task.print(fetchError.message); return TaskState.error; } @@ -185,8 +233,9 @@ class RepoTask extends Task { remoteID = log.replace(/\n/g, '').trim(); }); } catch (error) { - print(`Failed to fetch remote commit [ git rev-parse ${config.repo.name}/${config.repo.targetValue} ]`); - print(error as Error); + const err = error as Error; + task.print(`Failed to fetch remote commit [ git rev-parse ${config.repo.name}/${config.repo.targetValue} ]`); + task.print(err.message); return TaskState.error; } } else if (config.repo.targetType === 'tag') { @@ -198,12 +247,13 @@ class RepoTask extends Task { remoteID = log.replace(/\n/g, '').trim(); }); } catch (error) { - print(`Failed to fetch remote commit [ git rev-parse ${config.repo.name}/${config.repo.targetValue} ]`); - print(error as Error); + const err = error as Error; + task.print(`Failed to fetch remote commit [ git rev-parse ${config.repo.name}/${config.repo.targetValue} ]`); + task.print(err.message); return TaskState.error; } } else { - print('Failed to fetch remote commit [ No branch or tag configured ]'); + task.print('Failed to fetch remote commit [ No branch or tag configured ]'); return TaskState.error; } @@ -216,17 +266,18 @@ class RepoTask extends Task { localID = log.replace(/\n/g, '').trim(); }); } catch (error) { - print('Failed to retrieve local commits [ git rev-parse HEAD ]'); - print(error as Error); + const err = error as Error; + task.print('Failed to retrieve local commits [ git rev-parse HEAD ]'); + task.print(err.message); return TaskState.error; } // 打印 commit 对比信息 if (remoteID !== localID) { - print(`${localID} (local) => ${remoteID} (remote)`); + task.print(`${localID} (local) => ${remoteID} (remote)`); } else { // 本地远端 commit 相同 - print(`${remoteID} (local / remote)`); + task.print(`${remoteID} (local / remote)`); return TaskState.skip; } @@ -242,17 +293,18 @@ class RepoTask extends Task { }); if (isDirty) { if (!config.hard) { - print('Repository has modifications, skip update'); + task.print('Repository has modifications, skip update'); return TaskState.skip; } - print('Repository has modifications, stash changes'); + task.print('Repository has modifications, stash changes'); try { await bash(cmd.git, ['stash'], { cwd: path, }); } catch (error) { - print('Stashing changes failed, unable to proceed with code restoration'); - print(error as Error); + const err = error as Error; + task.print('Stashing changes failed, unable to proceed with code restoration'); + task.print(err.message); return TaskState.error; } } @@ -269,12 +321,13 @@ class RepoTask extends Task { } }); } catch (error) { - print('Failed to retrieve local commits [ git rev-parse HEAD ]'); - print(error as Error); + const err = error as Error; + task.print('Failed to retrieve local commits [ git rev-parse HEAD ]'); + task.print(err.message); return TaskState.error; } if (!isEditorBranch && !config.hard) { - print(`Not on the ${config.repo.local} branch, skipping update`); + task.print(`Not on the ${config.repo.local} branch, skipping update`); return TaskState.skip; } try { @@ -298,15 +351,16 @@ class RepoTask extends Task { }, (chunk) => { info += chunk; }); - print(`Restore code: ${info.trim()}`); + task.print(`Restore code: ${info.trim()}`); } catch (error) { - print('Failed to restore code'); - print(error as Error); + const err = error as Error; + task.print('Failed to restore code'); + task.print(err.message); return TaskState.error; } - print(`>> ${config.repo.url}`); - printEmpty(); + task.print(`>> ${config.repo.url}`); + task.print(' '); return TaskState.success; } @@ -317,4 +371,4 @@ class RepoTask extends Task { return err ? TaskState.error : TaskState.success; } } -registerTask(RepoTask); +registerTask('repo', RepoTask); diff --git a/source/task.ts b/source/task.ts index 62f6842..8edd96a 100644 --- a/source/task.ts +++ b/source/task.ts @@ -1,7 +1,12 @@ +/* eslint-disable max-classes-per-file */ import { join, dirname } from 'node:path'; -import { writeFileSync } from 'node:fs'; +import { writeFileSync, readFileSync } from 'node:fs'; -import { magenta, cyan } from 'chalk'; +import { magenta, cyan, dim } from 'chalk'; +import { + TaskManager, + Task as StructuresTask, +} from '@itharbors/structures'; import { formatTime, makeDir } from './utils'; @@ -18,6 +23,9 @@ interface workflowConfig { workspaces: string[]; } +export type baseType = string | number | boolean | undefined; +export type baseObject = { [key: string]: baseType | baseObject }; + /** * 任务状态 */ @@ -31,40 +39,93 @@ export enum TaskState { let workflowOption: workflowConfig | undefined; let workflowCacheJSON: { - [task: string]: { [key: string]: boolean | string | number | undefined }, + [task: string]: { [key: string]: baseObject }, } = {}; -export abstract class Task { - setCache(key: string, value: string | number | boolean | undefined) { +export class Task { + // 任务名称 + protected name: string; + + public messages: string[] = []; + + constructor(name: string) { + this.name = name; + } + + /** + * 获取最大并发数 + * @returns {number} + */ + static getMaxConcurrent() { + return 1; + } + + /** + * 设置缓存 + * @param key 缓存的键名 + * @param value 缓存的值 + */ + setCache(key: string, value: baseObject) { if (!workflowCacheJSON) { return; } - const name = this.getName(); - const data = workflowCacheJSON[name] = workflowCacheJSON[name] || {}; + + workflowCacheJSON[this.name] = workflowCacheJSON[this.name] || {}; + const data = workflowCacheJSON[this.name]; data[key] = value; } - getCache(key: string): string | number | boolean | undefined { + /** + * 获取缓存 + * @param key 缓存的键名 + * @returns 缓存的值,可能为空 + */ + getCache(key: string): baseObject { if (!workflowCacheJSON) { - return; + return {}; } - const name = this.getName(); - const data = workflowCacheJSON[name] = workflowCacheJSON[name] || {}; - return data[key]; + + workflowCacheJSON[this.name] = workflowCacheJSON[this.name] || {}; + const data = workflowCacheJSON[this.name]; + data[key] = data[key] || {}; + const result = data[key]; + return result; } + /** + * 获取缓存文件夹 + * @returns 缓存文件夹的绝对地址 + */ getCacheDir() { return workflowCacheJSON.cacheDir; } - abstract getName(): string; + /** + * 获取任务标题 + */ + getTitle(): string { + return ''; + } - abstract getTitle(): string; + /** + * 打印日志 + * @param str + */ + print(str: string) { + this.messages.push(str); + } - abstract execute(workspace: string, config: any): Promise | TaskState; + /** + * 执行任务 + * @param workspace + * @param config + */ + execute(workspace: string, config: any): Promise | TaskState { + return TaskState.unknown; + } } -const TaskMap = new Map(); +const TaskMap = new Map(); /** * 初始化工作流 @@ -73,7 +134,8 @@ const TaskMap = new Map(); export function initWorkflow(config: workflowConfig) { workflowOption = config; try { - workflowCacheJSON = require(config.cacheFile); + const str = readFileSync(config.cacheFile); + workflowCacheJSON = JSON.parse(str.toString()); } catch (error) { workflowCacheJSON = {}; } @@ -100,54 +162,101 @@ export async function executeTask(taskNameList: string[]) { // 开始任务的分割线 console.log(magenta(`${split} ${taskName} ${split}`)); - const task = TaskMap.get(taskName); - if (!task) { + const CacheTask = TaskMap.get(taskName); + if (!CacheTask) { continue; } - const result = results[taskName] = results[taskName] || []; + const manager = new TaskManager({ + name: `${taskName}`, + maxConcurrent: Task.getMaxConcurrent(), + }); - // 循环执行每一个工作区 - for (const workspace of workflowOption.workspaces) { - // 读取任务配置 - let configMap; - try { - const configFile = join(workspace, workflowOption!.entry); - configMap = require(configFile); - } catch (error) { - console.error(error); + results[taskName] = results[taskName] || []; + const result = results[taskName]; + class ExecTask extends StructuresTask { + private workspace: string; + + private entry: string; + + private params: any; + + private cacheFile: string; + + private workflowCacheJSON: { [key: string]: any }; + + constructor( + workspace: string, + entry: string, + params: any, + cacheFile: string, + workflowCacheJSON: { [key: string]: any }, + ) { + super(); + this.workspace = workspace; + this.entry = entry; + this.params = params; + this.cacheFile = cacheFile; + this.workflowCacheJSON = workflowCacheJSON; } - const config = await configMap[taskName](workflowOption.params); - console.log(cyan(workspace)); - - const vendorLog = console.log; - console.log = function (...args) { - const type = typeof args[0]; - if (type === 'string' || Buffer.isBuffer(args[0])) { - args[0] = ` ${args[0]}`; + + async handle() { + // 读取任务配置 + let configMap; + try { + const configFile = join(this.workspace, this.entry); + configMap = await import(configFile); + } catch (error) { + console.error(error); } - vendorLog.call(console, ...args); - }; - // console.log(` ▶ ${task.getTitle()}`); - // 执行任务 - const startTime = Date.now(); - try { - const state = await task.execute(workspace, config); - result.push(state); - } catch (error) { - console.error(error); - result.push(TaskState.error); + const config = await configMap[taskName](this.params); + + // 执行任务 + const startTime = Date.now(); + const task = new CacheTask!(taskName); + task.print(cyan(this.workspace)); + try { + const state = await task.execute(this.workspace, config); + result.push(state); + } catch (error) { + const err = error as Error; + task.print(err.message); + result.push(TaskState.error); + } + const endTime = Date.now(); + task.print(dim(`Workspace task execution completed. ${formatTime(endTime - startTime)}`)); + + // 输出缓存的日志 + task.messages.forEach((message) => { + console.log(` ${message}`); + }); + + // 每个小任务结束的时候,将配置重新写回文件 + const dir = dirname(this.cacheFile); + await makeDir(dir); + writeFileSync(this.cacheFile, JSON.stringify(this.workflowCacheJSON, null, 2)); } - const endTime = Date.now(); - console.log = vendorLog; + } - // 每个小任务结束的时候,将配置重新写回文件 - const dir = dirname(workflowOption.cacheFile); - await makeDir(dir); - writeFileSync(workflowOption.cacheFile, JSON.stringify(workflowCacheJSON, null, 2)); + // 循环执行每一个工作区 + for (const workspace of workflowOption.workspaces) { + const execTask = new ExecTask( + workspace, + workflowOption.entry, + workflowOption.params, + workflowOption.cacheFile, + workflowCacheJSON, + ); + manager.push(execTask); } - const taskEndTime = Date.now(); - console.log(magenta(`${split} ${taskName}(${formatTime(taskEndTime - taskStartTime)}) ${split}`)); + await new Promise((resolve) => { + manager.start(); + manager.addListener('finish', () => { + const taskEndTime = Date.now(); + console.log(magenta(`${split} ${taskName}(${formatTime(taskEndTime - taskStartTime)}) ${split}`)); + resolve(undefined); + }); + }); } return results; @@ -158,7 +267,6 @@ export async function executeTask(taskNameList: string[]) { * @param taskName * @param handle */ -export function registerTask(taskClass: new () => Task) { - const task = new taskClass(); - TaskMap.set(task.getName(), task); +export function registerTask(name: string, TaskClass: typeof Task) { + TaskMap.set(name, TaskClass); } diff --git a/test/task.spec.js b/test/task.spec.js index ec28a5a..3e13db6 100644 --- a/test/task.spec.js +++ b/test/task.spec.js @@ -1,6 +1,8 @@ -const { equal } = require('node:assert'); +const { equal, deepEqual } = require('node:assert'); const { describe, it, before } = require('node:test'); const { join } = require('node:path'); +const { spawnSync } = require('node:child_process'); +const { readFileSync, existsSync } = require('node:fs'); const { initWorkflow, @@ -31,11 +33,6 @@ describe('task', () => { // 注册测试任务 class TestTask extends Task { - // eslint-disable-next-line class-methods-use-this - getName() { - return 'test'; - } - // eslint-disable-next-line class-methods-use-this getTitle() { return '测试任务'; @@ -46,7 +43,7 @@ describe('task', () => { return TaskState.success; } } - registerTask(TestTask); + registerTask('test', TestTask); }); it('执行任务', async () => { @@ -59,43 +56,61 @@ describe('task', () => { }); }); - describe('clone 仓库', () => { + describe('clone', () => { + const baseDir = join(__dirname, './task/workspace-repo'); + const PATH = { + repo: join(baseDir, './.dist/repository'), + cache: join(baseDir, './.dist/cache.json'), + result: join(baseDir, './.dist/result.json'), + }; + before(() => { - // 初始化工作流 - initWorkflow({ - entry: '.test.config.js', - params: { - repo: [ - { - repo: { - name: '_test_origin_', - url: 'git@github.com:itharbors/workflow.git', - local: '_test_branch_', + spawnSync('node', [join(baseDir, './run.js')]); + }); - targetType: 'branch', - targetValue: 'main', - }, + it('检查仓库文件夹', async () => { + const exists = existsSync(PATH.repo); + equal(true, exists); + }); - path: './.dist/repository', - hard: true, - skip: false, - }, - ], - }, - cacheFile: join(__dirname, './task/.dist.cache.json'), - cacheDir: join(__dirname, './task/.dist-files'), - workspaces: [ - join(__dirname, './task/workspace-repo'), - ], - }); + it('检查缓存信息', async () => { + const cacheStr = readFileSync(PATH.cache, 'utf8'); + const cacheJSON = JSON.parse(cacheStr); + deepEqual({}, cacheJSON); }); - it('执行任务', async () => { - const results = await executeTask([ - 'repo', - ]); - equal(true, !!results.repo); - equal(TaskState.success, results.repo[0]); + it('检查运行结果', async () => { + const cacheStr = readFileSync(PATH.result, 'utf8'); + const cacheJSON = JSON.parse(cacheStr); + deepEqual({ + repo: ['success'], + }, cacheJSON); + }); + }); + + describe('tsc', () => { + const baseDir = join(__dirname, './task/workspace-tsc'); + const PATH = { + cache: join(baseDir, './.dist/cache.json'), + result: join(baseDir, './.dist/result.json'), + }; + + before(() => { + spawnSync('node', [join(baseDir, './run.js')]); + }); + + it('检查缓存信息', async () => { + const cacheStr = readFileSync(PATH.cache, 'utf8'); + const cacheJSON = JSON.parse(cacheStr); + equal(true, !!cacheJSON.tsc); + }); + + it('检查运行结果', async () => { + const cacheStr = readFileSync(PATH.result, 'utf8'); + const cacheJSON = JSON.parse(cacheStr); + deepEqual({ + tsc: ['success'], + }, cacheJSON); }); }); }); diff --git a/test/task/workspace-repo/run.js b/test/task/workspace-repo/run.js new file mode 100644 index 0000000..983dfe4 --- /dev/null +++ b/test/task/workspace-repo/run.js @@ -0,0 +1,39 @@ +const { writeFileSync } = require('node:fs'); +const { join } = require('node:path'); + +const { + initWorkflow, + executeTask, +} = require('../../../dist/task'); +require('../../../dist/internal'); + +initWorkflow({ + entry: '.test.config.js', + params: { + repo: [ + { + repo: { + name: '_test_origin_', + url: 'git@github.com:itharbors/workflow.git', + local: '_test_branch_', + + targetType: 'branch', + targetValue: 'main', + }, + + path: './.dist/repository', + hard: true, + skip: false, + }, + ], + }, + cacheFile: join(__dirname, './.dist/cache.json'), + cacheDir: join(__dirname, './.dist/files'), + workspaces: [ + __dirname, + ], +}); + +executeTask(['repo']).then((results) => { + writeFileSync(join(__dirname, '.dist/result.json'), JSON.stringify(results, null, 2)); +}); diff --git a/test/task/workspace-tsc/.test.config.js b/test/task/workspace-tsc/.test.config.js new file mode 100644 index 0000000..bad6004 --- /dev/null +++ b/test/task/workspace-tsc/.test.config.js @@ -0,0 +1,5 @@ +exports.tsc = function(params) { + return [ + './', + ]; +} diff --git a/test/task/workspace-tsc/run.js b/test/task/workspace-tsc/run.js new file mode 100644 index 0000000..d5c3575 --- /dev/null +++ b/test/task/workspace-tsc/run.js @@ -0,0 +1,22 @@ +const { writeFileSync } = require('node:fs'); +const { join } = require('node:path'); + +const { + initWorkflow, + executeTask, +} = require('../../../dist/task'); +require('../../../dist/internal'); + +initWorkflow({ + entry: '.test.config.js', + params: {}, + cacheFile: join(__dirname, './.dist/cache.json'), + cacheDir: join(__dirname, './.dist/files'), + workspaces: [ + __dirname, + ], +}); + +executeTask(['tsc']).then((results) => { + writeFileSync(join(__dirname, '.dist/result.json'), JSON.stringify(results, null, 2)); +});