├── .editorconfig ├── .gitattributes ├── .github └── workflows │ └── nodejs.yml ├── .gitignore ├── .npmrc ├── .vscode └── settings.json ├── .yo-rc.json ├── README.md ├── __tests__ ├── cli.test.js ├── filelist-handler.test.js ├── helpers.test.js ├── jsconfig.json ├── main.test.js └── stamp-handler.test.js ├── globals.d.ts ├── license ├── package.json ├── readme-assets ├── Demo-Advanced_File_Out.gif ├── Demo-OnlyIn_Filter_Console_Out.gif └── No_Options_Output_To_Console.gif ├── scripts └── perf-stress-test.js ├── src ├── cli.js ├── filelist-handler.js ├── helpers.js ├── index.js ├── stamp-handler.js └── types.ts ├── tsconfig.json └── tst-helpers.js /.editorconfig: -------------------------------------------------------------------------------- 1 | root = true 2 | 3 | [*] 4 | indent_style = tab 5 | end_of_line = lf 6 | charset = utf-8 7 | trim_trailing_whitespace = true 8 | insert_final_newline = true 9 | 10 | [*.yml] 11 | indent_style = space 12 | indent_size = 2 13 | -------------------------------------------------------------------------------- /.gitattributes: -------------------------------------------------------------------------------- 1 | * text=auto eol=lf 2 | -------------------------------------------------------------------------------- /.github/workflows/nodejs.yml: -------------------------------------------------------------------------------- 1 | name: Node.js CI 2 | 3 | on: 4 | push: 5 | branches: 6 | - main 7 | pull_request: 8 | branches: 9 | - main 10 | 11 | jobs: 12 | test: 13 | runs-on: ubuntu-latest 14 | strategy: 15 | matrix: 16 | node-version: [10.x, 12.x, 14.x] 17 | steps: 18 | - uses: actions/checkout@v3 19 | - name: Setup Node.js ${{ matrix.node-version }} 20 | uses: actions/setup-node@v3 21 | with: 22 | node-version: ${{ matrix.node-version }} 23 | # NOTE: This repo currently lacks a lockfile (e.g. `package-lock.json` file), which is why the below line is commented out 24 | # this is for reasons roughly enumerated here - https://docs.joshuatz.com/cheatsheets/node-and-npm/npm-general/#including-the-lockfile 25 | # If a lockfile is ever added, this should be uncommented to speed up setup 26 | # cache: 'npm' 27 | - name: Install dependencies 28 | run: npm install 29 | - name: Configure git for use with tests 30 | run: | 31 | git config --global user.name "GitHub Actions Tester" 32 | git config --global user.email "noop@example.com" 33 | - name: Run lint and tests 34 | run: npm run test 35 | - name: Collect / generate code coverage reporting 36 | run: npx --no-install nyc report --reporter=text-lcov 37 | - name: Upload code coverage reporting 38 | uses: codecov/codecov-action@v3 39 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | node_modules 2 | yarn.lock 3 | .nyc_output 4 | coverage 5 | scratchpad.js 6 | gittest 7 | __tests__/tempdir-* 8 | debug.txt 9 | *.0x 10 | dist 11 | isolate-*-v8.log 12 | *.clinic-*.html 13 | .clinic 14 | git-date-extractor*.tgz 15 | -------------------------------------------------------------------------------- /.npmrc: -------------------------------------------------------------------------------- 1 | package-lock=false 2 | -------------------------------------------------------------------------------- /.vscode/settings.json: -------------------------------------------------------------------------------- 1 | { 2 | "xo.enable": true 3 | } 4 | -------------------------------------------------------------------------------- /.yo-rc.json: -------------------------------------------------------------------------------- 1 | { 2 | "generator-nm": { 3 | "promptValues": { 4 | "githubUsername": "joshuatz", 5 | "website": "https://joshuatz.com" 6 | } 7 | } 8 | } -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # git-date-extractor 2 | [![Build Status](https://github.com/joshuatz/git-date-extractor/actions/workflows/nodejs.yml/badge.svg)](https://github.com/joshuatz/git-date-extractor/tree/main/.github/workflows/nodejs.yml) 3 | [![codecov](https://codecov.io/gh/joshuatz/git-date-extractor/badge.svg?branch=main)](https://codecov.io/gh/joshuatz/git-date-extractor?branch=main) 4 | [![npm](https://img.shields.io/npm/v/git-date-extractor)](https://www.npmjs.com/package/git-date-extractor) 5 | [![demos](https://img.shields.io/badge/demos-github-informational)](https://github.com/joshuatz/git-date-extractor-demos) 6 | 7 | > Easily extract file dates based on git history, and optionally cache in a easy to parse JSON file. 8 | 9 | I made this tool because, in trying to set up an automated static site deployment, I realized two important facts: 10 | 1. Git does not preserve OS timestamps (`git clone` will set file creation date to now) 11 | 2. However, you can use the git log to track a file's history and generate timestamps based on when it was: 12 | - First added 13 | - Last modified 14 | 15 | Essentially, this is a way to run a command and get back a list of `created` and `modified` timestamps based on `git` history, regardless of when the files were actually created on your computer. 16 | 17 | It can run as either a CLI tool, or via JS, and returns an easy to parse JSON object with filenames as the key, and UNIX timestamps for created/modified times as the values. 18 | 19 | > Demo repo: [joshuatz/git-date-extractor-demos](https://github.com/joshuatz/git-date-extractor-demos) 20 | 21 | ## Quick Demo (CLI Usage): 22 | 23 | ![Demo GIF](https://raw.githubusercontent.com/joshuatz/git-date-extractor/main/readme-assets/No_Options_Output_To_Console.gif) 24 | 25 | 26 | ## Install 27 | 28 | ``` 29 | $ npm install git-date-extractor 30 | ``` 31 | 32 | 33 | ## Usage 34 | 35 | Assume directory structure of: 36 | - `alpha.txt` 37 | - `bravo.txt` 38 | - `/subdir` 39 | - `charlie.txt` 40 | 41 | This script will produce an output of: 42 | ```json 43 | { 44 | "alpha.txt": { 45 | "created": 1568785761, 46 | "modified": 1568790468 47 | }, 48 | "bravo.txt": { 49 | "created": 1568785761, 50 | "modified": 1568790468 51 | }, 52 | "subdir/charlie.txt": { 53 | "created": 1568785762, 54 | "modified": 1568790368 55 | } 56 | } 57 | ``` 58 | 59 | 60 | ### Via JS 61 | 62 | Generic usage: 63 | ```js 64 | const gitDateExtractor = require('git-date-extractor'); 65 | 66 | const stamps = await gitDateExtractor.getStamps(optionsObject); 67 | ``` 68 | 69 | Sample demo: 70 | ```js 71 | // This will store stamps into `stamps` variable, as well as save to file 72 | const stamps = await gitDateExtractor.getStamps({ 73 | outputToFile: true, 74 | outputFileName: 'timestamps.json', 75 | projectRootPath: __dirname 76 | }); 77 | console.log(stamps); 78 | /** 79 | * Output looks like: 80 | */ 81 | /* 82 | { 83 | "alpha.txt": { 84 | "created": 1568785761, 85 | "modified": 1568790468 86 | }, 87 | "bravo.txt": { 88 | "created": 1568785761, 89 | "modified": 1568790468 90 | }, 91 | "subdir/charlie.txt": { 92 | "created": 1568785762, 93 | "modified": 1568790368 94 | } 95 | } 96 | */ 97 | 98 | ``` 99 | 100 | > If you prefer callback style of async, you can pass a callback function as the second argument to `getStamps`. 101 | 102 | ### Via CLI 103 | ``` 104 | $ npm install --global git-date-extractor 105 | ``` 106 | 107 | ``` 108 | $ git-date-extractor --help 109 | $ git-dates --help 110 | 111 | Usage 112 | $ git-date-extractor [input] 113 | $ git-dates [input] 114 | 115 | Options (all are optional): 116 | --outputToFile {boolean} [Default: false] 117 | --outputFileName {string} [Default: timestamps.json] 118 | --outputFileGitAdd {boolean} [Default: false*] *default=true if gitCommitHook is set 119 | --files {string[] | string} 120 | --onlyIn {string[] | string} 121 | --blockFiles {string[] | string} 122 | --allowFiles {string[] | string} 123 | --gitCommitHook {"post" | "pre" | "none"} [Default: "none"] 124 | --projectRootPath {string} 125 | --debug {boolean} [Default: false] 126 | 127 | Examples 128 | $ git-date-extractor 129 | { 130 | 'alpha.txt': { created: 1568789925, modified: 1568790468 }, 131 | 'bravo.txt': { created: 1568789925, modified: 1568790468 }, 132 | 'subdir/charlie.txt': { created: 1568789925, modified: 1568790368 } 133 | } 134 | $ git-date-extractor --files=[alpha.txt] --outputFileGitAdd=true --gitCommitHook=post 135 | timestamps updated 136 | ``` 137 | 138 | > For the CLI, you can pass files either directly via the `--files` flag, such as `--files=[alpha.txt,bravo.txt]`, or as final arguments to the command, such as `git-date-extractor --outputToFile=true alpha.txt bravo.txt` 139 | 140 | > For passing filenames to the CLI (e.g. with `xargs`), be careful of special characters and/or spaces in filenames. 141 | 142 | ## Options 143 | 144 | Both the CLI and the main method accept the same options: 145 | 146 | Option Key | CLI Alias | Description | Type | Default 147 | ---|---|---|---|--- 148 | outputToFile | out | Should the results be saved to file? | `boolean` | `false` 149 | outputFileName | outFile | Name of the file to save to (if applicable) | `string` | `timestamps.json` 150 | outputFileGitAdd | gitAdd | If saving to file, should the file be `git add`'ed after update?
Note: This will only stage the file, unless you set gitCommitHook=post, then it will commit it. | `boolean` | `false` if `gitCommitHook` is set to `none` 151 | files | file | Specific files to get timestamps for. These should either be full file paths (e.g. `C:\dir\file.txt`) or relative to root of the scanned dir | `string[] or string` | NA - if empty, script will scan entire dir 152 | onlyIn | dirs | Filter files by specific directory | `string[] or string` | NA 153 | blockFiles | blocklist | Block certain files from being tracked | `string[] or string` | NA 154 | allowFiles | approvelist | Exception list of filepaths that will override certain blocks.
See advanced examples section. | `string[] or string | NA 155 | gitCommitHook | git-stage | Use this if you are running this script on a git hook.
For example, use `post` and the script will append a new commit with the changed timestamp file. | `"pre"` or `"post"` or `"none"` | `"none"` 156 | projectRootPath | rootDir | Top level directory containing your files.
Script should be able to detect automatically, but can also pass to be safe. | `string` | Auto-detected based on `proccess.cwd()`
or `__dirname` 157 | debug | debug | Might output extra meta info related to the development of this module | `boolean` | `false` 158 | 159 | > Warning: The debug option actually slows down the speed of execution a little due to some overhead related to logging 160 | 161 | --- 162 | 163 | ## Advanced examples 164 | I tried to make this tool very customizable, maybe a bit too much so! In general, the easiest way to use it is either with no flags (autoscan all files) or with `files` set to specific files. 165 | 166 | Also, if you are calling it from the console, you probably always want `outputToFile` to be `true`, unless you really only want output in the console alone. 167 | 168 | Setting `files` makes it run the fastest, since then it doesn't need to scan for files. 169 | 170 | However, here are some more advanced examples: 171 | 172 | ### Allowing exceptions to files not in the approved directories 173 | Here is our example structure: 174 | - `alpha.txt` 175 | - `bravo.txt` 176 | - `charlie.txt` 177 | - `/subdir` 178 | - `delta.js` 179 | - `echo.js` 180 | ```javascript 181 | const options = { 182 | files: ['bravo.txt','subdir/delta.js','subdir/echo.js'], 183 | onlyIn: ['subdir'], 184 | allowFiles: ['bravo.txt'] 185 | } 186 | ``` 187 | With the above options, our results will include `bravo.txt`, even though it doesn't fall within `/subdir`, because the `allowFiles` flag is an override that will bypass the `onlyIn` rule. 188 | 189 | This is useful when calling the script via an automation, like a `git hook`, where the `files` argument is dynamic, but you there are certain files you never want to be blocked from being tracked. 190 | 191 | ### Automating the check in of the timestamp file into version control (`git add`) 192 | If you are tracking the timestamp JSON file in `git`, and updating it via a `git hook` such as `pre-commit`, then an issue you are going to run into is that every time you commit, the file will get updated, which means it needs to be re-added (staged), and so on. 193 | 194 | The `gitCommitHook` lets you tell the script that it is being triggered by a hook, and it will act accordingly. This also works in tandem with the `outputFileGitAdd` flag. If you specify... 195 | - `gitCommitHook: 'pre'` And/Or `outputFileGitAdd: true` 196 | - The timestamp file will be `git add`ed to staging 197 | - If you run this script as a pre-commit hook, this means that the timestamps file will seamlessly appear as part of the commit without needing to be manually added each time 198 | - `gitCommitHook: 'post'` (and `outputFileGitAdd` !== false) 199 | - After the updated timestamp file is staged, it will be committed as a new commit. 200 | - `--amend` is not used to inject it into the last commit, since this could easily trigger a hook loop 201 | 202 | Here is how I have this setup as a pre-commit hook. This is a little over-complicated; this could be simplified further: 203 | 204 | ```sh 205 | #!/bin/bash 206 | # Get list of changed files - everything but "deletes" 207 | git diff --cached --name-only --diff-filter=ACMRTUXB -z | xargs -0 git-date-extractor --gitCommitHook=pre --onlyIn=[md,images] --allowFiles=[README.md] --outputToFile=true --outputFileName=timestamps-cache.json 208 | ``` 209 | 210 | ### Integrating into a Static Site Generator 211 | See [my demo repo](https://github.com/joshuatz/git-date-extractor-demos) for a demo that uses this tool with GatsbyJS. 212 | 213 | ## Portfolio / Project Page 214 | https://joshuatz.com/projects/applications/git-date-extractor-npm-package-and-cli-tool/ 215 | 216 | ## Major updates 217 | Version | Date | Notes 218 | --- | --- | --- 219 | 4.0.1 | 1/7/2021 | Change builds to use `lf` instead of `crlf` 220 | 4.0.0 | 11/21/2020 | Tons of bug fixes, performance improvements (2x!), TS types, and more.

Major version bump due to changes in types and input handling. 221 | 3.0.0 | 5/31/2020 | Fix CLI projectRootPath issues, refactor tests, and cleanup. 222 | 2.0.0 | 11/01/2019 | Refactored a bunch of stuff to be async. Main export is now async, which makes it incompatible with the previous version and necessitated a major version bump. 223 | 224 | ## Limitations 225 | ### Environments With Shallow Git History 226 | If you use a offsite build / deploy system, your build process might be running in an environment with a *"shallow"* git history, perhaps without you even knowing it. These are environments that are not a true copy of the original git repo; instead they only hold a small subset of the history, perhaps even just the very last commit. Often these are cloned by using something like `git clone --depth 1 ...`. 227 | 228 | The problem with this is pretty immediate; if the only history that exists is the last commit, Git is going to *claim* that ***all*** files were created and modified whenever that commit was made. Furthermore, the timestamp of that commit might not even correspond with the true last commit, if your build system does something funky with rebasing or creating temporary deploy branches. 229 | 230 | My advice to work around this is pretty much the same as my general advice for the best use of this tool anyways - run it locally, and commit the results (as a file) to your repo. To streamline this process, you can automate it with a git hook, and even ensure that other collaborators do so as well by using something like [husky](https://www.npmjs.com/package/husky). 231 | 232 | > 🐛 - See my findings for Vercel summarized in [this issue](https://github.com/joshuatz/git-date-extractor/issues/7#issuecomment-682298968) 233 | 234 | ### Environments With No Git History 235 | For files that have no git history, this tool has limited accuracy when it comes to creation time (birthtime), especially on Linux, with Node < 10.16.0 and/or Kernel <= 4.11 (where statx is not available). For details on this, checkout [my related PR](https://github.com/joshuatz/git-date-extractor/issues/1) and [my blog post on the topic](https://joshuatz.com/posts/2019/unix-linux-file-creation-stamps-aka-birthtime-and-nodejs/). 236 | -------------------------------------------------------------------------------- /__tests__/cli.test.js: -------------------------------------------------------------------------------- 1 | const test = require('ava').default; 2 | const {makeTempDir, buildTestDir, testForStampInResults} = require('../tst-helpers'); 3 | 4 | const childProc = require('child_process'); 5 | const fse = require('fs-extra'); 6 | const {posixNormalize} = require('../src/helpers'); 7 | 8 | /** @type {string} */ 9 | let tempDirPath; 10 | /** @type {import('../src/types').UnpackedPromise>} */ 11 | let builderRes; 12 | 13 | const CLI_CALL_PATH = posixNormalize(`${__dirname}/../src/cli.js`); 14 | 15 | test.before(async () => { 16 | tempDirPath = await makeTempDir(); 17 | builderRes = await buildTestDir(tempDirPath, true); 18 | }); 19 | 20 | test.after.always(async () => { 21 | await fse.remove(tempDirPath); 22 | }); 23 | 24 | test('CLI Test, Auto-Detection of Project Directory', async t => { 25 | const outputFileName = 'explicit-stamps.json'; 26 | const consoleOut = childProc.execSync(`node ${CLI_CALL_PATH} --outputToFile --outputFileName ${outputFileName}`, { 27 | cwd: tempDirPath, 28 | encoding: 'utf8' 29 | }).toString(); 30 | t.regex(consoleOut, /Total execution time/mi); 31 | // Check JSON output 32 | const parsedJson = await fse.readJSON(`${tempDirPath}/${outputFileName}`); 33 | testForStampInResults(t, builderRes.testFilesRelative, parsedJson, [builderRes.testFilesRelative[".dotdir"].foxtrot]); 34 | }); 35 | 36 | test('CLI Test, Explicit Project Directory', async t => { 37 | const outputFileName = 'detection-stamps.json'; 38 | const consoleOut = childProc.execSync(`node ${CLI_CALL_PATH} --projectRootPath ${tempDirPath} --outputToFile --outputFileName ${outputFileName}`, { 39 | encoding: 'utf8' 40 | }).toString(); 41 | t.regex(consoleOut, /Total execution time/mi); 42 | // Check JSON output 43 | const parsedJson = await fse.readJSON(`${tempDirPath}/${outputFileName}`); 44 | testForStampInResults(t, builderRes.testFilesRelative, parsedJson, [builderRes.testFilesRelative[".dotdir"].foxtrot]); 45 | }); 46 | -------------------------------------------------------------------------------- /__tests__/filelist-handler.test.js: -------------------------------------------------------------------------------- 1 | const test = require('ava').default; 2 | const fse = require('fs-extra'); 3 | const FilelistHandler = require('../src/filelist-handler'); 4 | const {validateOptions} = require('../src/helpers'); 5 | const {makeTempDir, buildTestDir} = require('../tst-helpers'); 6 | 7 | /** 8 | * @typedef {ReturnType} TestFilePaths 9 | */ 10 | 11 | /** @type {string} */ 12 | let tempDirPath; 13 | /** @type {string} */ 14 | let tempSubDirPath; 15 | /** @type {TestFilePaths} */ 16 | let testFiles; 17 | /** @type {TestFilePaths} */ 18 | let testFilesRelative; 19 | 20 | // Create directory and files for testing 21 | test.before(async () => { 22 | tempDirPath = await makeTempDir(); 23 | tempSubDirPath = `${tempDirPath}/subdir`; 24 | const builderRes = await buildTestDir(tempDirPath, true); 25 | testFilesRelative = builderRes.testFilesRelative; 26 | testFiles = builderRes.testFiles; 27 | }); 28 | 29 | // Teardown dir and files 30 | test.after.always(async () => { 31 | await fse.remove(tempDirPath); 32 | }); 33 | 34 | test('Restricting files by directory (onlyIn)', t => { 35 | /** 36 | * @type {import('../src/types').InputOptions} 37 | */ 38 | const dummyOptions = { 39 | onlyIn: [tempSubDirPath], 40 | files: [], 41 | gitCommitHook: 'none', 42 | outputToFile: false, 43 | projectRootPath: tempDirPath 44 | }; 45 | const instance = new FilelistHandler(validateOptions(dummyOptions)); 46 | const expected = [ 47 | { 48 | fullPath: testFiles.subdir.delta, 49 | relativeToProjRoot: testFilesRelative.subdir.delta 50 | }, 51 | { 52 | fullPath: testFiles.subdir.echo, 53 | relativeToProjRoot: testFilesRelative.subdir.echo 54 | } 55 | ]; 56 | t.deepEqual(instance.filePaths, expected); 57 | }); 58 | 59 | test('Restrict by directory + allowFiles override', t => { 60 | // Without the use of allowFiles, normally alpha.txt would be blocked by the onlyIn option, since it is not in the subdir 61 | /** 62 | * @type {import('../src/types').InputOptions} 63 | */ 64 | const dummyOptions = { 65 | onlyIn: [tempSubDirPath], 66 | files: [testFiles.alpha, testFiles.subdir.delta, testFiles.subdir.echo], 67 | allowFiles: 'alpha.txt', 68 | gitCommitHook: 'none', 69 | outputToFile: false, 70 | projectRootPath: tempDirPath 71 | }; 72 | const instance = new FilelistHandler(validateOptions(dummyOptions)); 73 | const expected = [ 74 | { 75 | fullPath: testFiles.alpha, 76 | relativeToProjRoot: testFilesRelative.alpha 77 | }, 78 | { 79 | fullPath: testFiles.subdir.delta, 80 | relativeToProjRoot: testFilesRelative.subdir.delta 81 | }, 82 | { 83 | fullPath: testFiles.subdir.echo, 84 | relativeToProjRoot: testFilesRelative.subdir.echo 85 | } 86 | ]; 87 | t.deepEqual(instance.filePaths, expected); 88 | }); 89 | 90 | test('Restricting files by explicit file list', t => { 91 | /** 92 | * @type {import('../src/types').InputOptions} 93 | */ 94 | const dummyOptions = { 95 | onlyIn: [], 96 | files: [testFiles.alpha, testFiles.bravo, testFiles.subdir.delta, testFilesRelative.subdir.echo], 97 | blockFiles: ['bravo.txt'], 98 | gitCommitHook: 'none', 99 | outputToFile: false, 100 | projectRootPath: tempDirPath 101 | }; 102 | const instance = new FilelistHandler(validateOptions(dummyOptions)); 103 | const expected = [ 104 | { 105 | fullPath: testFiles.alpha, 106 | relativeToProjRoot: testFilesRelative.alpha 107 | }, 108 | { 109 | fullPath: testFiles.subdir.delta, 110 | relativeToProjRoot: testFilesRelative.subdir.delta 111 | }, 112 | { 113 | fullPath: testFiles.subdir.echo, 114 | relativeToProjRoot: testFilesRelative.subdir.echo 115 | } 116 | ]; 117 | t.deepEqual(instance.filePaths, expected); 118 | }); 119 | 120 | test('Testing automatic dir parsing and filtering, + block list', t => { 121 | /** 122 | * @type {import('../src/types').InputOptions} 123 | */ 124 | const dummyOptions = { 125 | onlyIn: [], 126 | blockFiles: [testFiles.alpha, testFiles.charlie], 127 | gitCommitHook: 'none', 128 | outputToFile: false, 129 | projectRootPath: tempDirPath 130 | }; 131 | const instance = new FilelistHandler(validateOptions(dummyOptions)); 132 | const expected = [ 133 | { 134 | fullPath: testFiles.bravo, 135 | relativeToProjRoot: testFilesRelative.bravo 136 | }, 137 | { 138 | fullPath: testFiles.space, 139 | relativeToProjRoot: testFilesRelative.space 140 | }, 141 | { 142 | fullPath: testFiles.specialChars, 143 | relativeToProjRoot: testFilesRelative.specialChars 144 | }, 145 | { 146 | fullPath: testFiles.subdir.delta, 147 | relativeToProjRoot: testFilesRelative.subdir.delta 148 | }, 149 | { 150 | fullPath: testFiles.subdir.echo, 151 | relativeToProjRoot: testFilesRelative.subdir.echo 152 | } 153 | ]; 154 | t.deepEqual(instance.filePaths, expected); 155 | }); 156 | -------------------------------------------------------------------------------- /__tests__/helpers.test.js: -------------------------------------------------------------------------------- 1 | const test = require('ava').default; 2 | const helpers = require('../src/helpers'); 3 | 4 | /** 5 | * Helpers testing 6 | */ 7 | test('posixNormalize', t => { 8 | const inputPath = 'c:\\dir\\myfile.js'; 9 | const normalized = helpers.posixNormalize(inputPath); 10 | t.is(normalized, 'c:/dir/myfile.js'); 11 | }); 12 | test('replaceZeros', t => { 13 | const input = { 14 | alpha: 0, 15 | bravo: 'str', 16 | nested: { 17 | notouch: 0, 18 | str: 'str' 19 | } 20 | }; 21 | const replaced = helpers.replaceZeros(input, 'replaced'); 22 | t.deepEqual(replaced, { 23 | alpha: 'replaced', 24 | bravo: 'str', 25 | nested: { 26 | notouch: 0, 27 | str: 'str' 28 | } 29 | }); 30 | }); 31 | 32 | test('git folder check', t => { 33 | // The entire test will fail if the overall project is not git-inited 34 | // First test the working directory 35 | t.true(helpers.getIsInGitRepo()); 36 | // Then go to hdd root and test there (should probably not be a git repo haha) 37 | t.falsy(helpers.getIsInGitRepo('/')); 38 | }); 39 | 40 | test('replaceInObj', t => { 41 | const inputObj = { 42 | alpha: 2, 43 | bravo: 'BRAVO', 44 | arr: ['ARRAY_TEST'], 45 | nested: { 46 | charlie: 4, 47 | nested: { 48 | echo: 'ECHO', 49 | delta: 6 50 | } 51 | } 52 | }; 53 | /** @param {string | number} input */ 54 | const replacer = function(input) { 55 | if (typeof (input) === 'string') { 56 | return input.toLowerCase(); 57 | } 58 | 59 | return input * 2; 60 | }; 61 | const expected = { 62 | alpha: 4, 63 | bravo: 'bravo', 64 | arr: ['array_test'], 65 | nested: { 66 | charlie: 8, 67 | nested: { 68 | echo: 'echo', 69 | delta: 12 70 | } 71 | } 72 | }; 73 | t.deepEqual(helpers.replaceInObj(inputObj, replacer), expected); 74 | }); 75 | 76 | test('isInNodeModules', t => { 77 | t.false(helpers.isInNodeModules()); 78 | t.true(helpers.isInNodeModules('/node_modules/test/test.txt')); 79 | }); 80 | 81 | test('Option validator', t => { 82 | /** 83 | * @type {import('../src/types').InputOptions} 84 | */ 85 | const dummyOptions = { 86 | files: '[alpha.txt, bravo.txt]', 87 | blockFiles: 'charlie.js', 88 | // @ts-ignore 89 | gitCommitHook: 'invalid' 90 | }; 91 | const actual = helpers.validateOptions(dummyOptions); 92 | t.deepEqual(actual, { 93 | outputToFile: false, 94 | outputFileName: undefined, 95 | outputFileGitAdd: undefined, 96 | files: ['alpha.txt', 'bravo.txt'], 97 | onlyIn: undefined, 98 | blockFiles: ['charlie.js'], 99 | allowFiles: [], 100 | gitCommitHook: 'none', 101 | projectRootPath: helpers.projectRootPath, 102 | projectRootPathTrailingSlash: helpers.projectRootPathTrailingSlash, 103 | debug: false 104 | }); 105 | }); 106 | 107 | test('Null destination', t => { 108 | t.true(['NUL', '/dev/null'].includes(helpers.getNullDestination())); 109 | }); 110 | 111 | test('semver info extractor', t => { 112 | const dummySemVer = 'v24.5.23-alpha+msvc'; 113 | const expected = { 114 | major: 24, 115 | minor: 5, 116 | patch: 23, 117 | suffix: 'alpha+msvc', 118 | releaseLabel: 'alpha', 119 | metadata: 'msvc' 120 | }; 121 | t.deepEqual(helpers.getSemverInfo(dummySemVer), expected); 122 | }); 123 | -------------------------------------------------------------------------------- /__tests__/jsconfig.json: -------------------------------------------------------------------------------- 1 | // This seems to be necessary, since root tsconfig.json does not include this directory to avoid compiling it to /dist 2 | { 3 | "compilerOptions": { 4 | "checkJs": true, 5 | "noImplicitAny": true 6 | } 7 | } 8 | -------------------------------------------------------------------------------- /__tests__/main.test.js: -------------------------------------------------------------------------------- 1 | const test = require('ava').default; 2 | const {iDebugLog, makeTempDir, buildTestDir, wasLastCommitAutoAddCache, testForStampInResults, touchFileSync, delay} = require('../tst-helpers'); 3 | 4 | const childProc = require('child_process'); 5 | const fse = require('fs-extra'); 6 | const main = require('../src'); 7 | const {posixNormalize, getKernelInfo, getSemverInfo} = require('../src/helpers'); 8 | 9 | // Set up some paths for testing 10 | const cacheFileName = 'cache.json'; 11 | /** @type {string[]} */ 12 | const tempDirPaths = []; 13 | 14 | // Max variance for time diff 15 | const maxTimeVarianceSec = 2; 16 | 17 | // Time tracking 18 | const perfTimings = { 19 | postCommit: { 20 | start: 0, 21 | stop: 0, 22 | elapsed: 0 23 | }, 24 | preCommit: { 25 | start: 0, 26 | stop: 0, 27 | elapsed: 0 28 | } 29 | }; 30 | 31 | /** 32 | * This is really a full integration test 33 | */ 34 | test('main - integration test - git post commit', async t => { 35 | // Create test dir 36 | const tempDirPath = await makeTempDir(); 37 | tempDirPaths.push(tempDirPath); 38 | const cacheFilePath = posixNormalize(`${tempDirPath}/${cacheFileName}`); 39 | const {testFiles, testFilesNamesOnly} = await buildTestDir(tempDirPath, true); 40 | const checkTimeDelayMs = 5000; 41 | // Git add the files, since we are emulating a post commit 42 | childProc.execSync('git add . && git commit -m "added files"', { 43 | cwd: tempDirPath 44 | }); 45 | // Wait a bit so that we can make sure there is a difference in stamps 46 | await delay(checkTimeDelayMs); 47 | // Touch alpha so it can be re-staged and committed - thus giving it a later modification stamp 48 | touchFileSync(testFiles.alpha, true); 49 | // Git commit all the files 50 | childProc.execSync('git add . && git commit -m "added files"', { 51 | cwd: tempDirPath 52 | }); 53 | // Now run full process - get stamps, save to file, etc. 54 | perfTimings.postCommit.start = (new Date()).getTime(); 55 | /** 56 | * @type {import('../src/types').InputOptions} 57 | */ 58 | const dummyOptions = { 59 | projectRootPath: tempDirPath, 60 | gitCommitHook: 'post', 61 | outputToFile: true, 62 | outputFileName: cacheFileName 63 | }; 64 | // Run 65 | const result = await main.getStamps(dummyOptions); 66 | perfTimings.postCommit.stop = (new Date()).getTime(); 67 | const savedResult = JSON.parse(fse.readFileSync(cacheFilePath).toString()); 68 | 69 | // Check that the value passed back via JS matches what was saved to JSON 70 | t.deepEqual(result, savedResult); 71 | // Check that last commit was from self 72 | t.truthy(wasLastCommitAutoAddCache(tempDirPath, cacheFileName)); 73 | 74 | // Check that actual numbers came back for stamps for files, 75 | // but ignore .dotdir, as those are blocked by default 76 | testForStampInResults(t, testFilesNamesOnly, result, [testFilesNamesOnly[".dotdir"].foxtrot]); 77 | 78 | // Check a specific file stamp to verify it makes sense 79 | const alphaStamp = result['alpha.txt']; 80 | t.true(typeof (alphaStamp.created) === 'number'); 81 | t.true(typeof (alphaStamp.modified) === 'number'); 82 | // Important: Check the time difference between file creation and modified. If processor failed, these will be the same due to file stat. If success, then there should be a 10 second diff between creation (file stat) and modified (git add) 83 | const timeDelay = Number(alphaStamp.modified) - Number(alphaStamp.created); 84 | // Assume a small variance is OK 85 | const timeDiff = Math.abs((Math.floor(checkTimeDelayMs / 1000)) - timeDelay); 86 | t.true(timeDiff <= maxTimeVarianceSec, `Diff between created and modified should have been ${Math.floor(checkTimeDelayMs / 1000)}, but was ${timeDelay}. This variance of ${timeDiff} is beyond the accepted variance of ${maxTimeVarianceSec} (In ${tempDirPath}).`); 87 | }); 88 | 89 | test('main - integration test - git pre commit', async t => { 90 | // Create test dir 91 | const tempDirPath = await makeTempDir(); 92 | tempDirPaths.push(tempDirPath); 93 | const {testFiles} = await buildTestDir(tempDirPath, true, cacheFileName); 94 | const checkTimeDelayMs = 8000; 95 | // Wait a bit so that we can make sure there is a difference in stamps 96 | await delay(checkTimeDelayMs); 97 | // Touch alpha so that it will have a different mtime value 98 | touchFileSync(testFiles.alpha, true); 99 | // Now run full process - get stamps, save to file, etc. 100 | /** 101 | * @type {import('../src/types').InputOptions} 102 | */ 103 | const dummyOptions = { 104 | projectRootPath: tempDirPath, 105 | gitCommitHook: 'pre', 106 | outputToFile: true, 107 | outputFileName: cacheFileName, 108 | outputFileGitAdd: true 109 | }; 110 | // Run 111 | perfTimings.preCommit.start = (new Date()).getTime(); 112 | const result = await main.getStamps(dummyOptions); 113 | perfTimings.preCommit.stop = (new Date()).getTime(); 114 | // Now the cache file should be *staged* but **not** committed, since we used `pre` 115 | t.falsy(wasLastCommitAutoAddCache(tempDirPath, cacheFileName)); 116 | // Check that actual numbers came back for stamps 117 | const alphaStamp = result['alpha.txt']; 118 | t.true(typeof (alphaStamp.created) === 'number'); 119 | t.true(typeof (alphaStamp.modified) === 'number'); 120 | 121 | /** 122 | * For Node v8 & v9, on any kernel version of linux, fs.stat does not return valid birthtime (aka creation time) 123 | * On newer Node (10.16.0+), they take advantage of glibc (2.28+) syscall to statx(), which is in kernel 4.11+, and returns good birthtime 124 | * So, skip test if (node v < 10.16.0 && linux) OR (node v >= 10.16.0 && linux && kernel < 4.11) 125 | */ 126 | let skipNonGitBirthTest = false; 127 | if (process.platform !== 'win32') { 128 | let hasStatX = true; 129 | const kInfo = getKernelInfo(); 130 | const nodeInfo = getSemverInfo(process.versions.node); 131 | if (kInfo.base < 5 && kInfo.major < 11) { 132 | hasStatX = false; 133 | } 134 | if (nodeInfo.major < 10 || (nodeInfo.major === 10 && nodeInfo.minor < 16)) { 135 | hasStatX = false; 136 | } 137 | if (hasStatX === false) { 138 | skipNonGitBirthTest = true; 139 | t.pass('Non-git-based birthtime test skipped for OS without statx() available'); 140 | } 141 | } 142 | if (skipNonGitBirthTest === false) { 143 | // Check time difference in stamps. Note that both modified and created stamps should be based off file stat, since no git history has been created 144 | const timeDelay = Number(alphaStamp.modified) - Number(alphaStamp.created); 145 | const timeDiff = Math.abs((Math.floor(checkTimeDelayMs / 1000)) - timeDelay); 146 | t.true(timeDiff <= maxTimeVarianceSec, `Diff between created and modified should have been ${Math.floor(checkTimeDelayMs / 1000)}, but was ${timeDelay}. This variance of ${timeDiff} is beyond the accepted variance of ${maxTimeVarianceSec} (In ${tempDirPath}).`); 147 | } 148 | }); 149 | 150 | // Teardown dir and files 151 | test.after.always(async () => { 152 | for (const k in perfTimings) { 153 | const key = /** @type {keyof typeof perfTimings} */ (k); 154 | perfTimings[key].elapsed = perfTimings[key].stop - perfTimings[key].start; 155 | } 156 | iDebugLog(perfTimings); 157 | await Promise.all(tempDirPaths.map(p => { 158 | return fse.remove(p); 159 | })); 160 | }); 161 | -------------------------------------------------------------------------------- /__tests__/stamp-handler.test.js: -------------------------------------------------------------------------------- 1 | const test = require('ava').default; 2 | const childProc = require('child_process'); 3 | const fse = require('fs-extra'); 4 | const main = require('../src'); 5 | const stHandler = require('../src/stamp-handler'); 6 | const {validateOptions} = require('../src/helpers'); 7 | const {wasLastCommitAutoAddCache, makeTempDir, buildTestDir, delay} = require('../tst-helpers'); 8 | 9 | /** @type {string[]} */ 10 | const createdTempDirPaths = []; 11 | 12 | const setup = async (cacheFileName = 'cache.json') => { 13 | const tempDirPath = await makeTempDir(); 14 | createdTempDirPaths.push(tempDirPath); 15 | const cacheFilePath = `${tempDirPath}/${cacheFileName}`; 16 | // Git init - will fail if git is not installed 17 | childProc.execSync(`git init`, { 18 | cwd: tempDirPath 19 | }); 20 | // Create JSON cacheFile 21 | const cacheObj = {}; 22 | await fse.createFile(cacheFilePath); 23 | await fse.writeFile(cacheFilePath, JSON.stringify(cacheObj, null, 2)); 24 | return { 25 | tempDirPath, 26 | cacheFilePath, 27 | cacheFileName 28 | }; 29 | }; 30 | 31 | test('Update cache file and auto-commit', async (t) => { 32 | const {tempDirPath, cacheFilePath, cacheFileName} = await setup(); 33 | const nowStamp = (new Date()).getTime(); 34 | // Save without touching git 35 | const cacheObj = { 36 | alpha: 'alpha', 37 | bravo: 240, 38 | nested: { 39 | charlie: nowStamp 40 | } 41 | }; 42 | /** 43 | * @type {import('../src/types').InputOptions} 44 | */ 45 | const dummyOptions = { 46 | files: [], 47 | // Note the use of "post" for gitCommitHook 48 | // This should trigger adding of the cache file to the commit 49 | gitCommitHook: 'post', 50 | projectRootPath: tempDirPath, 51 | outputToFile: true 52 | }; 53 | stHandler.updateTimestampsCacheFile(cacheFilePath, cacheObj, validateOptions(dummyOptions)); 54 | // Now read back the file and check 55 | const saved = await fse.readJSON(cacheFilePath); 56 | t.deepEqual(cacheObj, saved); 57 | 58 | // Check that the file was checked into git 59 | t.truthy(wasLastCommitAutoAddCache(tempDirPath, cacheFileName)); 60 | }); 61 | 62 | test('Reuse timestamps from cache file when possible', async (t) => { 63 | const {tempDirPath, cacheFilePath, cacheFileName} = await setup(); 64 | const {testFilesRelative} = await buildTestDir(tempDirPath, false, cacheFileName); 65 | const startTimeSec = Math.floor((new Date().getTime()) / 1000); 66 | // Add a cache stamp for alpha, with a creation date wayyyy in the past 67 | const fakeAlphaCreated = 100; 68 | // File that is outside of test set; should be left alone 69 | const nonTestingFileName = 'dont-modify-me.gif'; 70 | const fakeNonsenseStamp = -100; 71 | /** @type {import('../src/types').StampCache} */ 72 | const dummyCache = { 73 | [testFilesRelative.alpha]: { 74 | created: fakeAlphaCreated, 75 | modified: fakeAlphaCreated 76 | }, 77 | [nonTestingFileName]: { 78 | created: fakeNonsenseStamp, 79 | modified: fakeNonsenseStamp 80 | } 81 | }; 82 | await fse.writeJSON(cacheFilePath, dummyCache); 83 | /** 84 | * @type {import('../src/types').InputOptions} 85 | */ 86 | const dummyOptions = { 87 | files: [], 88 | projectRootPath: tempDirPath, 89 | outputToFile: true, 90 | outputFileName: cacheFileName 91 | }; 92 | await delay(1000); 93 | const result = await main.getStamps(dummyOptions); 94 | /** @type {import('../src/types').StampCache} */ 95 | const saved = await fse.readJSON(cacheFilePath); 96 | // Make sure that alpha.created was preserved in both JS result and saved file 97 | t.true(result[testFilesRelative.alpha].created === fakeAlphaCreated); 98 | t.true(saved[testFilesRelative.alpha].created === fakeAlphaCreated); 99 | // But, alpha should be updated 100 | t.true(result[testFilesRelative.alpha].modified !== fakeAlphaCreated); 101 | t.true(result[testFilesRelative.alpha].modified > (startTimeSec - 10)); 102 | // And, file outside our set should have been left alone 103 | t.true(saved[nonTestingFileName].created === fakeNonsenseStamp); 104 | t.true(saved[nonTestingFileName].modified === fakeNonsenseStamp); 105 | }); 106 | 107 | test('Allow running when some requested files are not found', async (t) => { 108 | const {tempDirPath, cacheFilePath, cacheFileName} = await setup(); 109 | const {testFilesRelative, testFilesNamesOnly} = await buildTestDir(tempDirPath, false, cacheFileName); 110 | // Fake file, does not exist 111 | const fakeFilePath = `${tempDirPath}/acb123.bat`; 112 | /** 113 | * @type {import('../src/types').InputOptions} 114 | */ 115 | const dummyOptions = { 116 | files: [testFilesRelative.charlie, fakeFilePath, `${tempDirPath}/${testFilesNamesOnly.bravo}`], 117 | projectRootPath: tempDirPath, 118 | outputToFile: true, 119 | outputFileName: cacheFileName 120 | }; 121 | // We are trying to get stamps for one file that does exist, and one that does not (ENOENT) 122 | const result = await main.getStamps(dummyOptions); 123 | /** @type {import('../src/types').StampCache} */ 124 | const saved = await fse.readJSON(cacheFilePath); 125 | t.false(fakeFilePath in result); 126 | t.true(testFilesRelative.charlie in result); 127 | t.true(testFilesRelative.bravo in saved); 128 | }); 129 | 130 | // Teardown - delete test files 131 | test.after.always(async () => { 132 | await Promise.all(createdTempDirPaths.map(p => { 133 | return fse.remove(p); 134 | })); 135 | }); 136 | -------------------------------------------------------------------------------- /globals.d.ts: -------------------------------------------------------------------------------- 1 | declare namespace NodeJS { 2 | interface Global { 3 | calledViaCLI?: boolean; 4 | } 5 | } 6 | -------------------------------------------------------------------------------- /license: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) Joshua Tzucker (joshuatz.com) 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated documentation files (the "Software"), to deal in the Software without restriction, including without limitation the rights to use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the Software, and to permit persons to whom the Software is furnished to do so, subject to the following conditions: 6 | 7 | The above copyright notice and this permission notice shall be included in all copies or substantial portions of the Software. 8 | 9 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. 10 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "git-date-extractor", 3 | "version": "4.0.1", 4 | "description": "Easily extract file dates based on git history, and optionally cache in a easy to parse JSON file.", 5 | "license": "MIT", 6 | "repository": { 7 | "type": "git", 8 | "url": "https://github.com/joshuatz/git-date-extractor.git" 9 | }, 10 | "bugs": { 11 | "url": "https://github.com/joshuatz/git-date-extractor/issues" 12 | }, 13 | "homepage": "https://github.com/joshuatz/git-date-extractor", 14 | "author": { 15 | "name": "Joshua Tzucker", 16 | "url": "https://joshuatz.com/?utm_source=gitdateextractor&utm_medium=package" 17 | }, 18 | "bin": { 19 | "git-date-extractor": "dist/cli.js", 20 | "git-dates": "dist/cli.js" 21 | }, 22 | "main": "dist/index.js", 23 | "engines": { 24 | "node": ">=10" 25 | }, 26 | "scripts": { 27 | "test": "npm run tsc-check && xo && nyc ava", 28 | "lint": "xo", 29 | "test-nolint": "nyc ava", 30 | "build": "rm -rf dist && tsc", 31 | "tsc-check": "tsc --noEmit", 32 | "benchmark": "node scripts/perf-stress-test.js" 33 | }, 34 | "files": [ 35 | "dist/" 36 | ], 37 | "types": "dist/index.d.ts", 38 | "keywords": [ 39 | "cli-app", 40 | "cli", 41 | "git", 42 | "date", 43 | "timestamp", 44 | "last-modified" 45 | ], 46 | "dependencies": { 47 | "fs-extra": "^8.1.0", 48 | "meow": "^7.0.1", 49 | "walkdir": "^0.4.1" 50 | }, 51 | "devDependencies": { 52 | "@types/fs-extra": "^9.0.1", 53 | "@types/node": "^14.0.5", 54 | "ava": "^3.8.2", 55 | "codecov": "^3.7.0", 56 | "nyc": "^15.0.1", 57 | "typescript": "^4.0.3", 58 | "xo": "^0.25.4" 59 | }, 60 | "nyc": { 61 | "reporter": [ 62 | "lcov", 63 | "text" 64 | ] 65 | }, 66 | "xo": { 67 | "rules": { 68 | "quotes": "off", 69 | "prefer-arrow-callback": "off", 70 | "padding-line-between-statements": "off", 71 | "space-before-function-paren": "off", 72 | "max-depth": "off", 73 | "no-path-concat": "off", 74 | "no-useless-escape": "off", 75 | "camelcase": "off", 76 | "complexity": "off", 77 | "arrow-parens": "off", 78 | "brace-style": "off", 79 | "unicorn/prevent-abbreviations": "off", 80 | "unicorn/no-for-loop": "off", 81 | "guard-for-in": "off", 82 | "unicorn/prefer-set-has": "off", 83 | "unicorn/better-regex": "off", 84 | "unicorn/prefer-number-properties": "off", 85 | "prefer-named-capture-group": "off" 86 | }, 87 | "ignores": [ 88 | "**/*d.ts", 89 | "src/types.ts", 90 | "dist" 91 | ] 92 | } 93 | } 94 | -------------------------------------------------------------------------------- /readme-assets/Demo-Advanced_File_Out.gif: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/joshuatz/git-date-extractor/ddf6ef24ab0d14d675c15a320ca3af405c31fda7/readme-assets/Demo-Advanced_File_Out.gif -------------------------------------------------------------------------------- /readme-assets/Demo-OnlyIn_Filter_Console_Out.gif: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/joshuatz/git-date-extractor/ddf6ef24ab0d14d675c15a320ca3af405c31fda7/readme-assets/Demo-OnlyIn_Filter_Console_Out.gif -------------------------------------------------------------------------------- /readme-assets/No_Options_Output_To_Console.gif: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/joshuatz/git-date-extractor/ddf6ef24ab0d14d675c15a320ca3af405c31fda7/readme-assets/No_Options_Output_To_Console.gif -------------------------------------------------------------------------------- /scripts/perf-stress-test.js: -------------------------------------------------------------------------------- 1 | // @ts-check 2 | const {makeTempDir, iDebugLog} = require('../tst-helpers'); 3 | const fse = require('fs-extra'); 4 | const {posixNormalize} = require('../src/helpers'); 5 | const childProc = require('child_process'); 6 | const main = require('../src'); 7 | 8 | const ROOT_FILE_COUNT = 120; 9 | // How many top-level subdirs to create 10 | const SUBDIR_ROOT_COUNT = 4; 11 | // Contents of top-level subdirs 12 | const SUBDIR_LEVELS = 3; 13 | const SUBDIR_FILE_COUNT = 20; 14 | const FILE_CONTENTS = `TEST FILE -- ⏱ --`; 15 | 16 | /** @type {string} */ 17 | let tempDirPath; 18 | 19 | const perfInfo = { 20 | testMeta: { 21 | timeToCreateFilesMs: 0, 22 | totalFileCount: 0 23 | }, 24 | results: { 25 | totalExecutionTimeMs: 0, 26 | totalExecutionTimeSec: 0, 27 | filesPerSec: 0 28 | } 29 | }; 30 | 31 | const timer = { 32 | fileCreation: { 33 | start: 0, 34 | stop: 0 35 | }, 36 | program: { 37 | start: 0, 38 | stop: 0 39 | } 40 | }; 41 | 42 | const getRandExtension = () => { 43 | const extensions = ['txt', 'md', 'html']; 44 | return extensions[Math.floor(Math.random() * extensions.length)]; 45 | }; 46 | 47 | const folderNames = ['alpha', 'bravo', 'charlie', 'delta']; 48 | const getRandFolderName = () => { 49 | return folderNames[Math.floor(Math.random() * folderNames.length)]; 50 | }; 51 | 52 | /** 53 | * 54 | * @param {string} folderPath 55 | * @param {number} num 56 | */ 57 | const getTestFilePath = (folderPath, num) => { 58 | const slash = folderPath.endsWith('/') ? '' : '/'; 59 | return `${folderPath}${slash}t-${num}.${getRandExtension()}`; 60 | }; 61 | 62 | const stressTest = async () => { 63 | const filePaths = []; 64 | timer.fileCreation.start = new Date().getTime(); 65 | tempDirPath = posixNormalize(await makeTempDir()); 66 | // Create root files 67 | for (let x = 0; x < ROOT_FILE_COUNT; x++) { 68 | filePaths.push(getTestFilePath(tempDirPath, x)); 69 | } 70 | // Create subdir files 71 | for (let x = 0; x < SUBDIR_ROOT_COUNT && x < folderNames.length; x++) { 72 | const baseFolderName = folderNames[x]; 73 | for (let s = 1; s < SUBDIR_LEVELS; s++) { 74 | const subDirPath = `${tempDirPath}/${baseFolderName}/${new Array(s).fill(0).map(getRandFolderName).join('/')}`; 75 | for (let f = 0; f < SUBDIR_FILE_COUNT; f++) { 76 | filePaths.push(getTestFilePath(subDirPath, f)); 77 | } 78 | } 79 | } 80 | 81 | // Wait for all files to be created 82 | await Promise.all(filePaths.map(async (p) => { 83 | await fse.ensureFile(p); 84 | await fse.writeFile(p, FILE_CONTENTS); 85 | })); 86 | timer.fileCreation.stop = new Date().getTime(); 87 | const totalFileCount = filePaths.length; 88 | perfInfo.testMeta = { 89 | timeToCreateFilesMs: timer.fileCreation.stop - timer.fileCreation.start, 90 | totalFileCount 91 | }; 92 | 93 | // Git init 94 | childProc.execSync('git init', { 95 | cwd: tempDirPath 96 | }); 97 | childProc.execSync('git add . && git commit -m "Adding files"', { 98 | cwd: tempDirPath 99 | }); 100 | 101 | // Run program 102 | timer.program.start = new Date().getTime(); 103 | /** @type {import('../src/types').InputOptions} */ 104 | const programOptions = { 105 | projectRootPath: tempDirPath, 106 | outputToFile: true 107 | }; 108 | const result = await main.getStamps(programOptions); 109 | timer.program.stop = new Date().getTime(); 110 | 111 | // Gather and return results 112 | console.assert(Object.keys(result).length === totalFileCount, 'Something went wrong with file generation, or program; # of files with date info does not match # supposed to be generated'); 113 | const totalExecutionTimeMs = timer.program.stop - timer.program.start; 114 | const totalExecutionTimeSec = totalExecutionTimeMs / 1000; 115 | perfInfo.results = { 116 | totalExecutionTimeMs, 117 | totalExecutionTimeSec, 118 | filesPerSec: totalFileCount / totalExecutionTimeSec 119 | }; 120 | return perfInfo; 121 | }; 122 | 123 | const teardown = async () => { 124 | await fse.remove(tempDirPath); 125 | }; 126 | 127 | const run = async() => { 128 | const results = await stressTest(); 129 | iDebugLog(results); 130 | await teardown(); 131 | }; 132 | 133 | run().then(() => { 134 | console.log(`Done!`); 135 | }); 136 | -------------------------------------------------------------------------------- /src/cli.js: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env node 2 | 'use strict'; 3 | global.calledViaCLI = true; 4 | const meow = require('meow'); 5 | const {validateOptions} = require('./helpers'); 6 | const gitDateExtractor = require('.'); 7 | 8 | const cli = meow(` 9 | Usage 10 | $ git-date-extractor [input] 11 | $ git-dates [input] 12 | 13 | Options (all are optional): 14 | --outputToFile {boolean} [Default: false] 15 | --outputFileName {string} [Default: timestamps.json] 16 | --outputFileGitAdd {boolean} [Default: false*] *default=true if gitCommitHook is set 17 | --files {string[] | string} 18 | --onlyIn {string[] | string} 19 | --blockFiles {string[] | string} 20 | --allowFiles {string[] | string} 21 | --gitCommitHook {"post" | "pre" | "none"} [Default: "none"] 22 | --projectRootPath {string} 23 | --debug {boolean} [Default: false] 24 | 25 | Examples 26 | $ git-date-extractor 27 | { 28 | 'alpha.txt': { created: 1568789925, modified: 1568790468 }, 29 | 'bravo.txt': { created: 1568789925, modified: 1568790468 }, 30 | 'subdir/charlie.txt': { created: 1568789925, modified: 1568790368 } 31 | } 32 | $ git-date-extractor --files=[alpha.txt] --outputFileGitAdd=true --gitCommitHook=post 33 | timestamps updated 34 | `, { 35 | flags: { 36 | outputToFile: { 37 | type: 'boolean', 38 | default: false, 39 | alias: 'out' 40 | }, 41 | outputFileName: { 42 | type: 'string', 43 | alias: 'outFile' 44 | }, 45 | outputFileGitAdd: { 46 | type: 'boolean', 47 | alias: 'gitAdd' 48 | }, 49 | files: { 50 | type: 'string', 51 | alias: 'file' 52 | }, 53 | onlyIn: { 54 | type: 'string', 55 | alias: 'dirs' 56 | }, 57 | blockFiles: { 58 | type: 'string', 59 | alias: 'blocklist' 60 | }, 61 | allowFiles: { 62 | type: 'string', 63 | alias: 'approvelist' 64 | }, 65 | gitCommitHook: { 66 | type: 'string', 67 | default: 'none', 68 | alias: 'git-stage' 69 | }, 70 | projectRootPath: { 71 | type: 'string', 72 | alias: 'rootDir' 73 | }, 74 | debug: { 75 | type: 'boolean' 76 | } 77 | } 78 | }); 79 | 80 | // Files can be passed either through flag OR just as args to cli 81 | const options = cli.flags; 82 | const finalizedOptions = validateOptions(options); 83 | const regularArgs = cli.input; 84 | finalizedOptions.files = finalizedOptions.files.concat(regularArgs); 85 | 86 | // Call main with options 87 | gitDateExtractor.getStamps(finalizedOptions).then(result => { 88 | if (finalizedOptions.outputToFile === false) { 89 | console.log(result); 90 | } else { 91 | const msg = 'timestamps file updated'; 92 | console.log(msg); 93 | } 94 | }).catch(error => { 95 | console.error(error); 96 | }); 97 | -------------------------------------------------------------------------------- /src/filelist-handler.js: -------------------------------------------------------------------------------- 1 | const path = require('path'); 2 | const fse = require('fs-extra'); 3 | const walkdir = require('walkdir'); 4 | const {posixNormalize, getIsRelativePath} = require('./helpers'); 5 | 6 | const internalDirBlockList = [ 7 | 'node_modules', 8 | '.git' 9 | ]; 10 | const internalDirBlockPatterns = [ 11 | // Block all .___ directories 12 | /^\..*$/, 13 | // Block all __tests__ and similar 14 | /^__[^_]+__$/ 15 | ]; 16 | const internalFileBlockPatterns = [ 17 | // .__ files 18 | /^\..+$/ 19 | ]; 20 | 21 | class FilelistHandler { 22 | /** @param {import('./types').FinalizedOptions} optionsObj */ 23 | constructor(optionsObj) { 24 | this.inputOptions = optionsObj; 25 | 26 | /** 27 | * @type Array<{relativeToProjRoot:string, fullPath: string}> 28 | */ 29 | this.filePaths = []; 30 | 31 | // Parse filter options 32 | /** 33 | * Construct a list of directories that will be scanned for files 34 | */ 35 | this.contentDirs = [optionsObj.projectRootPath]; 36 | if (Array.isArray(optionsObj.onlyIn) && optionsObj.onlyIn.length > 0) { 37 | this.contentDirs = optionsObj.onlyIn; 38 | } 39 | this.fullPathContentDirs = this.contentDirs.map(function(pathStr) { 40 | return posixNormalize(getIsRelativePath(pathStr) ? (optionsObj.projectRootPath + '/' + pathStr) : pathStr); 41 | }); 42 | 43 | this.alwaysAllowFileNames = optionsObj.allowFiles; 44 | this.alwaysAllowFilePaths = this.alwaysAllowFileNames.map(function(pathStr) { 45 | return posixNormalize(getIsRelativePath(pathStr) ? (optionsObj.projectRootPath + '/' + pathStr) : pathStr); 46 | }); 47 | this.restrictByDir = Array.isArray(optionsObj.onlyIn) && optionsObj.onlyIn.length > 0; 48 | this.usesCache = typeof (optionsObj.outputFileName) === 'string'; 49 | this.usesBlockFiles = Array.isArray(optionsObj.blockFiles) && optionsObj.blockFiles.length > 0; 50 | // Process input files 51 | for (let x = 0; x < optionsObj.files.length; x++) { 52 | let filePath = optionsObj.files[x]; 53 | // Make sure to get full file path 54 | filePath = posixNormalize(getIsRelativePath(filePath) ? (optionsObj.projectRootPathTrailingSlash + filePath) : filePath); 55 | this.pushFilePath(filePath, true); 56 | } 57 | /** 58 | * If no files were passed through the explicit "files" option, this block will walk through directories to scan for files 59 | */ 60 | if (optionsObj.files.length === 0) { 61 | // Get *all* files contained within content dirs 62 | // Iterate over all dirs of interest 63 | for (let x = 0; x < this.fullPathContentDirs.length; x++) { 64 | const fullContentDirPath = this.fullPathContentDirs[x]; 65 | // Walk the dir and built paths 66 | const paths = walkdir.sync(fullContentDirPath, function(pathStr) { 67 | const pathDirName = path.basename(pathStr); 68 | let blocked = false; 69 | // Check internal block list of directories 70 | if (internalDirBlockList.includes(pathDirName)) { 71 | blocked = true; 72 | } 73 | if (!blocked) { 74 | for (let db = 0; db < internalDirBlockPatterns.length; db++) { 75 | if (internalDirBlockPatterns[db].test(pathDirName)) { 76 | blocked = true; 77 | break; 78 | // DebugLog('Blocked based on DirBlockPatt - ' + pathDirName); 79 | } 80 | } 81 | } 82 | if (blocked) { 83 | this.ignore(pathStr); 84 | } 85 | }); 86 | // Walk the individual files and check 87 | for (let p = 0; p < paths.length; p++) { 88 | let blocked = false; 89 | const fileOrDirName = path.basename(paths[p]); 90 | for (let b = 0; b < internalFileBlockPatterns.length; b++) { 91 | if (internalFileBlockPatterns[b].test(fileOrDirName)) { 92 | blocked = true; 93 | break; 94 | } 95 | } 96 | if (!blocked) { 97 | this.pushFilePath(paths[p], false); 98 | } 99 | } 100 | } 101 | } 102 | } 103 | 104 | /** 105 | * Checks if a file is on the allowFiles list (aka approved) 106 | * @param {string} filePath - the filepath to check 107 | * @returns {boolean} - If the file is on the approved list 108 | */ 109 | getIsFileOnApproveList(filePath) { 110 | const fileName = path.basename(filePath); 111 | if (this.alwaysAllowFileNames.includes(fileName)) { 112 | return true; 113 | } 114 | if (this.alwaysAllowFilePaths.includes(filePath)) { 115 | return true; 116 | } 117 | return false; 118 | } 119 | 120 | /** 121 | * Add a file to the queue of file paths to retrieve dates for 122 | * @param {string} filePath - The path of the file 123 | * @param {boolean} [checkExists] - If the func should check that the file actually exists before adding 124 | * @returns {boolean} - If the file was added 125 | */ 126 | pushFilePath(filePath, checkExists) { 127 | filePath = posixNormalize(filePath); 128 | if (this.getShouldTrackFile(filePath, checkExists)) { 129 | this.filePaths.push({ 130 | relativeToProjRoot: filePath.replace(posixNormalize(this.inputOptions.projectRootPathTrailingSlash), ''), 131 | fullPath: filePath 132 | }); 133 | return true; 134 | } 135 | return false; 136 | } 137 | 138 | /** 139 | * @param {string} filePath - The path of the file 140 | * @param {boolean} [checkExists] - If the func should check that the file actually exists before adding 141 | * @returns {boolean} - If the file should be tracked / dates fetched 142 | */ 143 | getShouldTrackFile(filePath, checkExists) { 144 | let shouldBlock = false; 145 | filePath = posixNormalize(filePath); 146 | const fileName = path.basename(filePath); 147 | checkExists = typeof (checkExists) === "boolean" ? checkExists : false; 148 | 149 | // Block tracking the actual timestamps file - IMPORTANT: blocks hook loop! 150 | if (this.usesCache && filePath.includes(posixNormalize(this.inputOptions.outputFileName))) { 151 | // Only let this be overrwritten by allowFiles approvelist if gitcommithook is equal to 'none' or unset 152 | if (this.inputOptions.gitCommitHook === 'pre' || this.inputOptions.gitCommitHook === 'post') { 153 | return false; 154 | } 155 | shouldBlock = true; 156 | } 157 | 158 | // Triggered by options.onlyIn 159 | if (this.restrictByDir) { 160 | let found = false; 161 | // Block tracking any files outside the indicated content dirs 162 | for (let x = 0; x < this.fullPathContentDirs.length; x++) { 163 | const fullContentDirPath = this.fullPathContentDirs[x]; 164 | if (filePath.includes(posixNormalize(fullContentDirPath))) { 165 | found = true; 166 | break; 167 | } 168 | } 169 | if (!found) { 170 | // Not in content dirs - block adding 171 | shouldBlock = true; 172 | } 173 | } 174 | 175 | // Block tracking any on blocklist 176 | if (this.usesBlockFiles && this.inputOptions.blockFiles.includes(fileName)) { 177 | shouldBlock = true; 178 | } 179 | if (this.usesBlockFiles && this.inputOptions.blockFiles.includes(filePath)) { 180 | shouldBlock = true; 181 | } 182 | /* istanbul ignore if */ 183 | let exists = true; 184 | try { 185 | if (fse.lstatSync(filePath).isDirectory() === true) { 186 | return false; 187 | } 188 | exists = true; 189 | // eslint-disable-next-line no-unused-vars 190 | } catch (error) { 191 | exists = false; 192 | } 193 | if (checkExists && !exists) { 194 | return false; 195 | } 196 | if (shouldBlock) { 197 | // Let override with allowFiles 198 | if (this.getIsFileOnApproveList(filePath)) { 199 | return true; 200 | } 201 | 202 | return false; 203 | } 204 | 205 | return true; 206 | } 207 | } 208 | 209 | module.exports = FilelistHandler; 210 | -------------------------------------------------------------------------------- /src/helpers.js: -------------------------------------------------------------------------------- 1 | const path = require('path'); 2 | const childProc = require('child_process'); 3 | const os = require('os'); 4 | const fse = require('fs-extra'); 5 | 6 | /** 7 | * Normalizes and forces a filepath to the forward slash variant 8 | * Example: \dir\file.txt will become /dir/file.txt 9 | * @param {string} filePath the path to normalize 10 | * @returns {string} The posix foward slashed version of the input 11 | */ 12 | const posixNormalize = function(filePath) { 13 | return path.normalize(filePath).replace(/[\/\\]{1,2}/gm, '/'); 14 | }; 15 | 16 | /** 17 | * Extract an array from a stringified array 18 | * @param {string} str - input string 19 | * @returns {string[]} - Array output 20 | */ 21 | function extractArrFromStr(str) { 22 | /** @type {string[]} */ 23 | let arr = []; 24 | if (typeof (str) === 'string') { 25 | // Test for input string resembling array 26 | // "[alpha.txt, bravo.js]" 27 | if (/^\[(.+)\]/.exec(str)) { 28 | // Extract arr 29 | arr = /^\[(.+)\]/.exec(str)[1].split(',').map(e => e.trim()); 30 | } else { 31 | // Single file - e.g. "alpha.txt" 32 | arr.push(str); 33 | } 34 | } 35 | return arr; 36 | } 37 | 38 | /** 39 | * Internal options validator / modder 40 | * @param {object} input - Options object 41 | * @returns {import('./types').FinalizedOptions} - The finalized, formatted options 42 | */ 43 | function _validateOptions(input) { 44 | const moddedOptions = JSON.parse(JSON.stringify(input)); 45 | /** 46 | * Fill in some defaults and check for invalid combos 47 | */ 48 | if (typeof (moddedOptions.projectRootPath) !== 'string' || moddedOptions.projectRootPath.length === 0) { 49 | moddedOptions.projectRootPath = projectRootPath; 50 | } 51 | // Make sure project root does not end with trailing slash 52 | if (/[\/\\]{0,2}$/.test(moddedOptions.projectRootPath)) { 53 | // Remove trailing slashes 54 | moddedOptions.projectRootPath = moddedOptions.projectRootPath.replace(/[\/\\]{0,2}$/, ''); 55 | } 56 | moddedOptions.projectRootPath = posixNormalize(moddedOptions.projectRootPath); 57 | moddedOptions.projectRootPathTrailingSlash = posixNormalize(moddedOptions.projectRootPath + '/'); 58 | if (typeof (moddedOptions.outputToFile) !== 'boolean') { 59 | moddedOptions.outputToFile = false; 60 | } 61 | if (moddedOptions.outputToFile) { 62 | // Default outputFileName 63 | if (typeof (moddedOptions.outputFileName) !== 'string' || moddedOptions.outputFileName.length === 0) { 64 | moddedOptions.outputFileName = 'timestamps.json'; 65 | } 66 | } 67 | if (typeof moddedOptions.outputFileName === 'string') { 68 | // Force outputFile (e.g. the cache file) to a full path if it is not 69 | if (!path.isAbsolute(moddedOptions.outputFileName)) { 70 | moddedOptions.outputFileName = posixNormalize(`${moddedOptions.projectRootPath}/${moddedOptions.outputFileName}`); 71 | } 72 | } 73 | // Default git commit hook selection 74 | if (typeof (moddedOptions.gitCommitHook) !== 'string') { 75 | moddedOptions.gitCommitHook = 'none'; 76 | } 77 | // Reset invalid git commit hook selection 78 | if (typeof (moddedOptions.gitCommitHook) === 'string' && ['pre', 'post', 'none'].includes(moddedOptions.gitCommitHook) === false) { 79 | moddedOptions.gitCommitHook = 'none'; 80 | } 81 | // Force single file passed to array 82 | if (typeof (moddedOptions.files) === 'string') { 83 | moddedOptions.files = extractArrFromStr(moddedOptions.files); 84 | } 85 | if (typeof (moddedOptions.onlyIn) === 'string') { 86 | moddedOptions.onlyIn = extractArrFromStr(moddedOptions.onlyIn); 87 | } 88 | if (typeof (moddedOptions.blockFiles) === 'string') { 89 | moddedOptions.blockFiles = extractArrFromStr(moddedOptions.blockFiles); 90 | } 91 | if (typeof (moddedOptions.allowFiles) === 'string') { 92 | moddedOptions.allowFiles = extractArrFromStr(moddedOptions.allowFiles); 93 | } 94 | // Force to array 95 | if (!Array.isArray(moddedOptions.files)) { 96 | // Reminder: An empty files array means that all files within the project space will be scanned! 97 | moddedOptions.files = []; 98 | } 99 | if (!Array.isArray(moddedOptions.allowFiles)) { 100 | moddedOptions.allowFiles = []; 101 | } 102 | // Debug - auto set to true if local dev 103 | /* istanbul ignore if */ 104 | if (typeof (moddedOptions.debug) !== 'boolean') { 105 | if (/.+\/laragon\/.+\/git-date-extractor-debug\/.*/.test(posixNormalize(__dirname))) { 106 | moddedOptions.debug = true; 107 | } else { 108 | moddedOptions.debug = false; 109 | } 110 | } 111 | return moddedOptions; 112 | } 113 | 114 | /** 115 | * Validates input options and forces them to conform 116 | * @param {import('./types').InputOptions} input - Options 117 | * @returns {import('./types').FinalizedOptions} - The vaidated and formatted options 118 | */ 119 | function validateOptions(input) { 120 | const moddedOptions = _validateOptions(input); 121 | /** 122 | * @type {import('./types').FinalizedOptions} 123 | */ 124 | const finalOptions = { 125 | outputToFile: moddedOptions.outputToFile, 126 | outputFileName: moddedOptions.outputFileName, 127 | outputFileGitAdd: moddedOptions.outputFileGitAdd, 128 | files: moddedOptions.files, 129 | onlyIn: moddedOptions.onlyIn, 130 | blockFiles: moddedOptions.blockFiles, 131 | allowFiles: moddedOptions.allowFiles, 132 | gitCommitHook: moddedOptions.gitCommitHook, 133 | projectRootPath: moddedOptions.projectRootPath, 134 | projectRootPathTrailingSlash: moddedOptions.projectRootPathTrailingSlash, 135 | debug: moddedOptions.debug 136 | }; 137 | return finalOptions; 138 | } 139 | 140 | /** 141 | * Run a replacer function over an object to modify it 142 | * @param {{[k: string]: any}} inputObj - the object to replace values in 143 | * @param {(input: any) => any} replacerFunc - cb func to take value, modify, and return it 144 | * @returns {{[k: string]: any}} - Object with replacements 145 | */ 146 | function replaceInObj(inputObj, replacerFunc) { 147 | /** @type {{[k: string]: any}} */ 148 | const outputObj = {}; 149 | for (let x = 0; x < Object.keys(inputObj).length; x++) { 150 | const key = Object.keys(inputObj)[x]; 151 | let val = inputObj[Object.keys(inputObj)[x]]; 152 | if (Array.isArray(val)) { 153 | for (let y = 0; y < val.length; y++) { 154 | val[y] = replacerFunc(val[y]); 155 | } 156 | } else if (val && typeof (val) === 'object') { 157 | val = replaceInObj(val, replacerFunc); 158 | } else { 159 | val = replacerFunc(val); 160 | } 161 | outputObj[key] = val; 162 | } 163 | return outputObj; 164 | } 165 | 166 | /** 167 | * Get the "null" destination 168 | * @returns {string} - The "null" destination 169 | */ 170 | function getNullDestination() { 171 | if (process.platform === 'win32') { 172 | return 'NUL'; 173 | } 174 | 175 | return '/dev/null'; 176 | } 177 | const nullDestination = getNullDestination(); 178 | 179 | /** 180 | * Are we in a subdirectory of the node_modules folder? 181 | * @param {string} [OPT_path] - Optional path to use as check dir 182 | * @returns {boolean} - If we are in node_modules 183 | */ 184 | const isInNodeModules = function(OPT_path) { 185 | if (typeof (OPT_path) === 'string') { 186 | return /node_modules\//.test(OPT_path); 187 | } 188 | 189 | const parentFolderPath = path.normalize(__dirname + '/../'); 190 | /* istanbul ignore if */ 191 | if (path.basename(parentFolderPath) === 'node_modules') { 192 | return true; 193 | } 194 | 195 | return false; 196 | }; 197 | 198 | /** 199 | * Check if a value is a valid stamp value 200 | * @param {any} stampInt - The stamp value to check 201 | * @returns {boolean} - Is valid stamp val 202 | */ 203 | function getIsValidStampVal(stampInt) { 204 | if (typeof (stampInt) !== 'number' || stampInt <= 0) { 205 | return false; 206 | } 207 | return true; 208 | } 209 | 210 | /** 211 | * Checks if two are objects are same (inefficient and bad - uses stringify) 212 | * @param {object} objA - First obj 213 | * @param {object} objB - Second obj 214 | * @returns {boolean} - Are two objs same? 215 | */ 216 | function lazyAreObjsSame(objA, objB) { 217 | if (JSON.stringify(objA) === JSON.stringify(objB)) { 218 | return true; 219 | } 220 | 221 | return false; 222 | } 223 | 224 | /** 225 | * Get the lowest num out of array of nums 226 | * @param {number[]} numArr - array of numbers 227 | * @returns {number} - lowest number in arr 228 | */ 229 | function getLowest(numArr) { 230 | return numArr.sort((a, n) => { 231 | return a - n; 232 | })[0]; 233 | } 234 | 235 | /** 236 | * Get the highest num out of array of nums 237 | * @param {number[]} numArr - array of numbers 238 | * @returns {number} - highest number in arr 239 | */ 240 | function getHighest(numArr) { 241 | return numArr.sort((a, n) => { 242 | return n - a; 243 | })[0]; 244 | } 245 | 246 | /** 247 | * Get the highest and lowest stamps from FS Stats 248 | * @param {import('fs').Stats} stats - FS Stats object 249 | * @returns {{lowestMs: number, highestMs: number}} - Highest and lowest points 250 | */ 251 | function getEndOfRangeFromStat(stats) { 252 | const lowestMs = getLowest([ 253 | stats.birthtimeMs, 254 | stats.atimeMs, 255 | stats.ctimeMs, 256 | stats.mtimeMs 257 | ]); 258 | const highestMs = getHighest([ 259 | stats.birthtimeMs, 260 | stats.atimeMs, 261 | stats.ctimeMs, 262 | stats.mtimeMs 263 | ]); 264 | return { 265 | lowestMs, 266 | highestMs 267 | }; 268 | } 269 | 270 | /** 271 | * @typedef {Object} BirthStamps 272 | * @property {number} birthtimeMs - Birth time in MS since Epoch 273 | * @property {number} birthtime - Birth time in sec since Epoch 274 | * @property {string} source - Where did the info come from 275 | */ 276 | 277 | /** 278 | * Get the birth times of a file 279 | * @param {string} filePath - The filepath of the file to get birth of 280 | * @param {boolean} [preferNative] - Prefer using Node FS - don't try for debugfs 281 | * @param {import('fs-extra').Stats} [OPT_fsStats] - Stats object, if you already have it ready 282 | * @returns {Promise} - Birth stamps 283 | */ 284 | async function getFsBirth(filePath, preferNative, OPT_fsStats) { 285 | /** @type {BirthStamps} */ 286 | const birthStamps = { 287 | birthtime: null, 288 | birthtimeMs: null, 289 | source: 'fs', 290 | errorMsg: '' 291 | }; 292 | /** 293 | * @type {import('fs-extra').Stats} 294 | */ 295 | let fsStats; 296 | 297 | // Check for passed in value 298 | if (typeof (fsStats) === 'object' && 'birthtimeMs' in fsStats) { 299 | fsStats = OPT_fsStats; 300 | } else { 301 | fsStats = await statPromise(filePath); 302 | } 303 | if (parseFloat(process.versions.node) > 9 || preferNative || process.platform === 'win32') { 304 | // Just use FS 305 | birthStamps.birthtimeMs = Math.round(getEndOfRangeFromStat(fsStats).lowestMs); 306 | birthStamps.birthtime = Math.round(birthStamps.birthtimeMs / 1000); 307 | } else { 308 | let success = true; 309 | // There is likely going to be an issue where mtime = birthtime, regardless of creation. Workaround hack: 310 | try { 311 | // Grab inode number, and device 312 | const inode = fsStats.ino; 313 | const fullStatStr = await execPromise(`stat ${filePath}`); 314 | const deviceStr = /Device:\s{0,1}([a-zA-Z0-9\/]+)/.exec(fullStatStr)[1]; 315 | // Make call to debugfs 316 | const debugFsInfo = await execPromise(`debugfs -R 'stat <${inode}> --format=%W' ${deviceStr}`); 317 | // Parse for timestamp 318 | const birthTimeSec = parseInt(debugFsInfo, 10); 319 | if (!Number.isNaN(birthTimeSec) && birthTimeSec !== 0) { 320 | // Success! 321 | birthStamps.birthtime = birthTimeSec; 322 | birthStamps.birthtimeMs = birthTimeSec * 1000; 323 | birthStamps.source = 'debugfs'; 324 | success = true; 325 | } else { 326 | // Bad - we still get back either 0 as birthTime, or bad string 327 | birthStamps.errorMsg = debugFsInfo; 328 | success = false; 329 | } 330 | } catch (error) { 331 | success = false; 332 | birthStamps.errorMsg = error.toString(); 333 | } 334 | if (!success) { 335 | // Fallback to fs 336 | return getFsBirth(filePath, true, fsStats); 337 | } 338 | } 339 | return birthStamps; 340 | } 341 | 342 | /** 343 | * @typedef {Object} SemVerInfo 344 | * @property {number} major 345 | * @property {number} minor 346 | * @property {number} patch 347 | * @property {string} suffix 348 | * @property {string} releaseLabel 349 | * @property {string} metadata 350 | */ 351 | 352 | /** 353 | * Get numerical semver info from string 354 | * Is kind of loose about input format 355 | * @param {string} versionStr - Version string. For example, from `process.versions.node` 356 | * @returns {SemVerInfo} - SemVer numerical info as obj 357 | */ 358 | function getSemverInfo(versionStr) { 359 | const info = { 360 | major: 0, 361 | minor: 0, 362 | patch: 0, 363 | suffix: '', 364 | releaseLabel: '', 365 | metadata: '' 366 | }; 367 | // Just in case vstring start with 'v' 368 | versionStr = versionStr.replace(/^v/, ''); 369 | const chunks = versionStr.split('-'); 370 | if (/(\d+)\.(\d+)\.(\d+)/.test(chunks[0])) { 371 | const vChunks = chunks[0].split('.'); 372 | info.major = parseInt(vChunks[0], 10); 373 | info.minor = parseInt(vChunks[1], 10); 374 | info.patch = parseInt(vChunks[2], 10); 375 | } 376 | if (chunks.length > 1) { 377 | // Suffix should look like 'beta.1+buildDebug' 378 | info.suffix = chunks[1]; 379 | const suffixChunks = info.suffix.split('+'); 380 | info.releaseLabel = typeof (suffixChunks[0]) === 'string' ? suffixChunks[0] : ''; 381 | info.metadata = typeof (suffixChunks[1]) === 'string' ? suffixChunks[1] : ''; 382 | } 383 | return info; 384 | } 385 | 386 | /** 387 | * @typedef {Object} KernelInfo 388 | * @property {number} base 389 | * @property {number} major 390 | * @property {number} minor 391 | * @property {number} patch 392 | */ 393 | /** 394 | * Get kernel version of OS (or v # in case of Win) 395 | * @returns {KernelInfo} - Kernel # 396 | */ 397 | /* istanbul ignore next */ 398 | function getKernelInfo() { 399 | const info = { 400 | base: 0, 401 | major: 0, 402 | minor: 0, 403 | patch: 0 404 | }; 405 | const kString = os.release(); 406 | const chunks = kString.split('-'); 407 | if (/(\d+)\.(\d+)\.(\d+)/.test(chunks[0])) { 408 | const vChunks = chunks[0].split('.'); 409 | info.base = parseInt(vChunks[0], 10); 410 | info.major = parseInt(vChunks[1], 10); 411 | info.minor = parseInt(vChunks[2], 10); 412 | } 413 | if (/\d+/.test(chunks[1])) { 414 | info.patch = parseInt(chunks[1], 10); 415 | } 416 | return info; 417 | } 418 | 419 | /** 420 | * Promise wrapper around child_process exec 421 | * @param {string} cmdStr - Command to execute 422 | * @param {import('child_process').ExecOptions} [options] - Exec options 423 | * @returns {Promise} - Stdout string 424 | */ 425 | function execPromise(cmdStr, options) { 426 | return new Promise((resolve, reject) => { 427 | childProc.exec(cmdStr, options, (error, stdout) => { 428 | if (error) { 429 | reject(error); 430 | return; 431 | } 432 | resolve(stdout); 433 | }); 434 | }); 435 | } 436 | 437 | /** 438 | * Promise wrapper around child_process.spawn 439 | * @param {string} cmdStr 440 | * @param {string[]} [args] 441 | * @param {import('child_process').SpawnOptions} [options] 442 | * @returns {Promise} stdout 443 | */ 444 | function spawnPromise(cmdStr, args, options) { 445 | return new Promise((resolve, reject) => { 446 | let out = ''; 447 | const spawned = childProc.spawn(cmdStr, args, options); 448 | spawned.stdout.on('data', data => { 449 | out += data.toString(); 450 | }); 451 | spawned.stderr.on('data', data => { 452 | out += data.toString(); 453 | }); 454 | spawned.on('error', reject); 455 | spawned.on('close', (exitCode) => { 456 | if (exitCode === 0) { 457 | resolve(out); 458 | } else { 459 | reject(out); 460 | } 461 | }); 462 | }); 463 | } 464 | 465 | /** 466 | * Get return value of a promise, with a default value, in case it falls 467 | * @param {Promise} promise 468 | * @param {any} [defaultVal] 469 | */ 470 | async function failSafePromise(promise, defaultVal = null) { 471 | let res = defaultVal; 472 | try { 473 | res = await promise; 474 | // eslint-disable-next-line no-unused-vars 475 | } catch (error) { 476 | // Ignore 477 | } 478 | return res; 479 | } 480 | 481 | /** 482 | * Promise wrapper around fs-extra stat 483 | * @param {string} filePath - Filepath to stat 484 | * @returns {Promise} 485 | */ 486 | function statPromise(filePath) { 487 | return new Promise((resolve, reject) => { 488 | fse.stat(filePath, (err, stats) => { 489 | if (err) { 490 | reject(err); 491 | return; 492 | } 493 | resolve(stats); 494 | }); 495 | }); 496 | } 497 | 498 | /** 499 | * Replaces any root level values on an object that are 0, with a different value 500 | * @param {{[k: string]: any}} inputObj - The object to replace zeros on 501 | * @param {any} replacement - what to replace the zeros with 502 | * @returns {object} The object with zeros replaced 503 | */ 504 | function replaceZeros(inputObj, replacement) { 505 | const keys = Object.keys(inputObj); 506 | for (let x = 0; x < keys.length; x++) { 507 | if (inputObj[keys[x]] === 0) { 508 | inputObj[keys[x]] = replacement; 509 | } 510 | } 511 | return inputObj; 512 | } 513 | 514 | /** 515 | * Test whether or not we are in a git initialized repo space / folder 516 | * @param {string} [OPT_folder] - Optional: Folder to use as dir to check in 517 | * @returns {boolean} Whether or not in git repo 518 | */ 519 | function getIsInGitRepo(OPT_folder) { 520 | let executeInPath = __dirname; 521 | if (typeof (OPT_folder) === 'string') { 522 | executeInPath = path.normalize(OPT_folder); 523 | } 524 | try { 525 | childProc.execSync(`git status`, { 526 | cwd: executeInPath 527 | }); 528 | return true; 529 | // eslint-disable-next-line no-unused-vars 530 | } catch (error) { 531 | return false; 532 | } 533 | } 534 | 535 | /** 536 | * Return whether or not a filepath is a relative path 537 | * @param {string} filePath - Filepath to check 538 | * @returns {boolean} - If it is, or is not, a relative path. 539 | */ 540 | function getIsRelativePath(filePath) { 541 | return !path.isAbsolute(filePath); 542 | } 543 | 544 | // @todo this is probably going to need to be revised 545 | let projectRootPath = isInNodeModules() ? posixNormalize(path.normalize(`${__dirname}/../..`)) : posixNormalize(`${__dirname}`); 546 | const callerDir = posixNormalize(process.cwd()); 547 | if (projectRootPath.includes(posixNormalize(__dirname)) || projectRootPath.includes(callerDir) || global.calledViaCLI) { 548 | projectRootPath = callerDir; 549 | } 550 | const projectRootPathTrailingSlash = projectRootPath + '/'; 551 | 552 | module.exports = { 553 | posixNormalize, 554 | replaceZeros, 555 | getIsInGitRepo, 556 | replaceInObj, 557 | projectRootPath, 558 | callerDir, 559 | projectRootPathTrailingSlash, 560 | getIsRelativePath, 561 | isInNodeModules, 562 | validateOptions, 563 | extractArrFromStr, 564 | getNullDestination, 565 | nullDestination, 566 | getIsValidStampVal, 567 | lazyAreObjsSame, 568 | getFsBirth, 569 | getKernelInfo, 570 | getSemverInfo, 571 | execPromise, 572 | spawnPromise, 573 | statPromise, 574 | failSafePromise 575 | }; 576 | -------------------------------------------------------------------------------- /src/index.js: -------------------------------------------------------------------------------- 1 | 'use strict'; 2 | const readline = require('readline'); 3 | const fse = require('fs-extra'); 4 | // @ts-ignore 5 | const packageInfo = require('../package.json'); 6 | const {posixNormalize, getIsInGitRepo, validateOptions, lazyAreObjsSame, callerDir} = require('./helpers'); 7 | const {updateTimestampsCacheFile, getTimestampsFromFilesBulk} = require('./stamp-handler'); 8 | const FilelistHandler = require('./filelist-handler'); 9 | 10 | /** 11 | * Main - called by CLI and the main export 12 | * @param {import('./types').InputOptions} options - input options 13 | * @param {function} [opt_cb] - Optional callback 14 | * @returns {Promise} - Stamp or info object 15 | */ 16 | async function main(options, opt_cb) { 17 | const perfTimings = { 18 | start: (new Date()).getTime(), 19 | stop: 0, 20 | elapsed: 0 21 | }; 22 | const optionsObj = validateOptions(options); 23 | /* istanbul ignore if */ 24 | if (optionsObj.debug) { 25 | console.log(` 26 | === Git Date Extractor, DEBUG=ON === 27 | ${packageInfo.version} 28 | ==================================== 29 | `); 30 | console.log({ 31 | finalizedOptions: optionsObj, 32 | callerDir 33 | }); 34 | } 35 | /* istanbul ignore if */ 36 | if (!getIsInGitRepo(optionsObj.projectRootPath)) { 37 | throw (new Error(`Fatal Error: You are not in a git initialized project space! Please run git init in ${optionsObj.projectRootPath}.`)); 38 | } 39 | /** 40 | * @type {import('./types').StampCache} 41 | */ 42 | let timestampsCache = {}; 43 | const readCacheFile = typeof (optionsObj.outputFileName) === 'string' && optionsObj.outputFileName.length > 0; 44 | let readCacheFileSuccess = false; 45 | let readCacheFileContents = {}; 46 | const writeCacheFile = readCacheFile && optionsObj.outputToFile; 47 | // Load in cache if applicable 48 | if (readCacheFile) { 49 | if (fse.existsSync(optionsObj.outputFileName) === false) { 50 | if (optionsObj.debug) { 51 | console.log(`Warning: Cache file ${optionsObj.outputFileName} does not already exist.`); 52 | } 53 | if (optionsObj.outputToFile) { 54 | fse.writeFileSync(optionsObj.outputFileName, '{}'); 55 | } 56 | } else { 57 | try { 58 | timestampsCache = JSON.parse(fse.readFileSync(optionsObj.outputFileName).toString()); 59 | readCacheFileSuccess = true; 60 | // Lazy clone obj 61 | readCacheFileContents = JSON.parse(JSON.stringify(timestampsCache)); 62 | // eslint-disable-next-line no-unused-vars 63 | } catch (error) { 64 | console.log(`Warning: Could not read in cache file @ ${optionsObj.outputFileName}`); 65 | } 66 | } 67 | } 68 | // Get filepaths 69 | const {filePaths} = new FilelistHandler(optionsObj); 70 | /** 71 | * Now iterate through filepaths to get stamps 72 | */ 73 | if (filePaths.length > 0) { 74 | // Add line break 75 | console.log(`${filePaths.length} files queued up. Starting scrape...\n`); 76 | } 77 | 78 | /** 79 | * @type {Array<{fullPath: string, localPath: string}>} 80 | */ 81 | const filesToGet = []; 82 | 83 | filePaths.forEach((filePathMeta, index) => { 84 | let currFullPath = filePathMeta.fullPath; 85 | let currLocalPath = filePathMeta.relativeToProjRoot; 86 | /* istanbul ignore if */ 87 | if (optionsObj.debug) { 88 | // Nice progress indicator in console 89 | const consoleMsg = `Starting scrape of date info for file #${index + 1} / ${filePaths.length} ---> ${currLocalPath}`; 90 | if (process.stdout && readline) { 91 | readline.clearLine(process.stdout, 0); 92 | readline.cursorTo(process.stdout, 0, null); 93 | process.stdout.write(consoleMsg); 94 | // If this is the last loop, close out the line with a newline 95 | if (index === filePaths.length - 1) { 96 | process.stdout.write('\n'); 97 | } 98 | } else { 99 | console.log(consoleMsg); 100 | } 101 | } 102 | // Normalize path, force to posix style forward slash 103 | currFullPath = posixNormalize(currFullPath); 104 | currLocalPath = posixNormalize(currLocalPath); 105 | 106 | filesToGet.push({ 107 | fullPath: currFullPath, 108 | localPath: currLocalPath 109 | }); 110 | }); 111 | 112 | // Get stamps in bulk 113 | const results = await getTimestampsFromFilesBulk(filesToGet.map(f => { 114 | return { 115 | fullFilePath: f.fullPath, 116 | cacheKey: f.localPath, 117 | resultKey: f.localPath 118 | }; 119 | }), optionsObj, timestampsCache, false); 120 | // Update results object 121 | timestampsCache = { 122 | ...timestampsCache, 123 | ...results 124 | }; 125 | 126 | // Check if we need to write out results to disk 127 | if (writeCacheFile) { 128 | // Check for diff 129 | if (!readCacheFileSuccess || !lazyAreObjsSame(readCacheFileContents, timestampsCache)) { 130 | updateTimestampsCacheFile(optionsObj.outputFileName, timestampsCache, optionsObj); 131 | } else { 132 | console.log('Saving of timestamps file skipped - nothing changed'); 133 | } 134 | } 135 | 136 | // Check for callback 137 | if (typeof (opt_cb) === 'function') { 138 | opt_cb(timestampsCache); 139 | } 140 | 141 | perfTimings.stop = (new Date()).getTime(); 142 | perfTimings.elapsed = perfTimings.stop - perfTimings.start; 143 | console.log(`Total execution time = ${(perfTimings.elapsed / 1000).toFixed(2)} seconds.`); 144 | return timestampsCache; 145 | } 146 | 147 | /** 148 | * Run the extractor with options 149 | * @param {import('./types').InputOptions} options - input options 150 | * @param {function} [opt_cb] - Optional callback 151 | * @returns {Promise} - stamp object or info obj 152 | */ 153 | async function getStamps(options, opt_cb) { 154 | return main(options, opt_cb); 155 | } 156 | 157 | module.exports = { 158 | getStamps 159 | }; 160 | -------------------------------------------------------------------------------- /src/stamp-handler.js: -------------------------------------------------------------------------------- 1 | const childProc = require('child_process'); 2 | const fse = require('fs-extra'); 3 | const {replaceZeros, getIsInGitRepo, getIsValidStampVal, getFsBirth, statPromise, spawnPromise, failSafePromise} = require('./helpers'); 4 | 5 | /** 6 | * Updates the timestamp cache file and checks it into source control, depending on settings 7 | * @param {string} cacheFilePath - the path of the files to save the cache out to 8 | * @param {Object} jsonObj - The updated timestamps JSON to save to file 9 | * @param {import('./types').FinalizedOptions} optionsObj - Options 10 | */ 11 | function updateTimestampsCacheFile(cacheFilePath, jsonObj, optionsObj) { 12 | const {gitCommitHook} = optionsObj; 13 | const gitDir = optionsObj.projectRootPath; 14 | let shouldGitAdd = false; 15 | if (typeof (optionsObj.outputFileGitAdd) === 'boolean') { 16 | shouldGitAdd = optionsObj.outputFileGitAdd; 17 | } else if (gitCommitHook.toString() !== 'none') { 18 | shouldGitAdd = true; 19 | } 20 | /** 21 | * Save back updated timestamps to file 22 | */ 23 | fse.writeFileSync(cacheFilePath, JSON.stringify(jsonObj, null, 2)); 24 | /** 25 | * Since the timestamps file should be checked into source control, and we just modified it, re-add to commit and amend 26 | */ 27 | if (shouldGitAdd && getIsInGitRepo(gitDir)) { 28 | // Stage the changed file 29 | childProc.execSync(`git add ${cacheFilePath}`, { 30 | cwd: gitDir 31 | }); 32 | if (gitCommitHook.toString() === 'post') { 33 | // Since the commit has already happened, we need to re-stage the changed timestamps file, and then commit it as a new commit 34 | // WARNING: We cannot use git commit --amend because that will trigger an endless loop if this file is triggered on a git post-commit loop! 35 | // Although the below will trigger the post-commit hook again, the loop should be blocked by the filepath checker at the top of the script that excludes the timestamp JSON file from being tracked 36 | childProc.execSync(`git commit -m "AUTO: Updated ${optionsObj.outputFileName}"`, { 37 | cwd: gitDir 38 | }); 39 | } 40 | } 41 | } 42 | 43 | /** 44 | * @typedef {object} FileToCheckMeta 45 | * @property {string} fullFilePath - The *full* file path to get stamps for 46 | * @property {string} [cacheKey] - What is the stamp currently stored under for this file? 47 | * @property {string} [resultKey] - If provided, will be the value used as the key in the results object. Default = fullFilePath 48 | */ 49 | 50 | /** 51 | * 52 | * @param {Array} filesToGet - Files to retrieve stamps for 53 | * @param {import('./types').FinalizedOptions} optionsObj - Options 54 | * @param {import('./types').StampCache} [cache] - Object with key/pair values corresponding to valid stamps 55 | * @param {boolean} [forceCreatedRefresh] If true, any existing created stamps in cache will be ignored, and re-calculated 56 | */ 57 | async function getTimestampsFromFilesBulk(filesToGet, optionsObj, cache, forceCreatedRefresh) { 58 | const {gitCommitHook} = optionsObj; 59 | const ignoreCreatedCache = typeof (forceCreatedRefresh) === 'boolean' ? forceCreatedRefresh : false; 60 | const timestampsCache = typeof (cache) === 'object' ? cache : {}; 61 | /** @type {import('child_process').SpawnOptions} */ 62 | const execOptions = { 63 | stdio: 'pipe', 64 | cwd: optionsObj.projectRootPath 65 | }; 66 | 67 | // Try to run things as concurrently as possible 68 | /** @type {Array>} */ 69 | const promiseArr = []; 70 | 71 | for (let f = 0; f < filesToGet.length; f++) { 72 | promiseArr.push((async () => { 73 | const fileMeta = filesToGet[f]; 74 | const {fullFilePath, cacheKey} = fileMeta; 75 | // Lookup values in cache 76 | /** 77 | * @type {import('./types').StampObject} 78 | */ 79 | let dateVals = timestampsCache[cacheKey]; 80 | dateVals = typeof (dateVals) === 'object' ? dateVals : { 81 | created: 0, 82 | modified: 0 83 | }; 84 | 85 | try { 86 | /* istanbul ignore else */ 87 | if (!dateVals.created || ignoreCreatedCache) { 88 | // Get the created stamp by looking through log and following history 89 | // Remember - for created, we want the very **first** commit, which in the git log, is actually the *oldest* and *last* commit 90 | 91 | // NEED to either wrap with try/catch, or create helper utility to wrap any promise with return null if fail, etc. 92 | 93 | let createdStamp = null; 94 | const createdStampsLog = await failSafePromise(spawnPromise(`git`, [`log`, `--follow`, `--pretty=format:%at`, `--`, fullFilePath], execOptions), null); 95 | if (createdStampsLog) { 96 | // Need to basically run `tail -n 1`, grab last line 97 | const createdStamps = createdStampsLog.split(/[\r\n]+/m); 98 | createdStamp = Number(createdStamps[createdStamps.length - 1]); 99 | } 100 | if (!getIsValidStampVal(createdStamp) && gitCommitHook.toString() !== 'post') { 101 | // During pre-commit, a file could be being added for the first time, so it wouldn't show up in the git log. We'll fall back to OS stats here 102 | // createdStamp = Math.floor(fse.statSync(fullFilePath).birthtimeMs / 1000); 103 | createdStamp = (await getFsBirth(fullFilePath)).birthtime; 104 | } 105 | if (Number.isNaN(createdStamp) === false) { 106 | dateVals.created = createdStamp; 107 | } 108 | } 109 | 110 | // Always update modified stamp regardless 111 | let modifiedStamp = null; 112 | if (gitCommitHook === 'none' || gitCommitHook === 'post') { 113 | // If this is running after the commit that modified the file, we can use git log to pull the modified time out 114 | // Modified should be the most recent and at the top of the log 115 | modifiedStamp = await failSafePromise(spawnPromise(`git`, [`log`, `-1`, `--pretty=format:%at`, `--follow`, `--`, fullFilePath], execOptions), null); 116 | } 117 | modifiedStamp = Number(modifiedStamp); 118 | if (gitCommitHook === 'pre' || !getIsValidStampVal(modifiedStamp)) { 119 | // If this is running before the changed files have actually be commited, they either won't show up in the git log, or the modified time in the log will be from one commit ago, not the current 120 | // Pull modified time from file itself 121 | const fsStats = await statPromise(fullFilePath); 122 | modifiedStamp = Math.floor(fsStats.mtimeMs / 1000); 123 | } 124 | if (Number.isNaN(modifiedStamp) === false) { 125 | dateVals.modified = modifiedStamp; 126 | } 127 | // Check for zero values - this might be the case if there is no git history - new file 128 | // If there is a zero, replace with current Unix stamp, but make sure to convert from JS MS to regular S 129 | dateVals = replaceZeros(dateVals, Math.floor((new Date()).getTime() / 1000)); 130 | } catch (error) { 131 | /* istanbul ignore next */ 132 | console.log(`getting git dates failed for ${fullFilePath}`, error); 133 | } 134 | 135 | return { 136 | fileMeta, 137 | stamps: dateVals 138 | }; 139 | })()); 140 | } 141 | // Wait for all promises to resolve, combine results, indexed by full path 142 | /** 143 | * @typedef {{[k: string]: import('./types').StampObject}} CombinedResults 144 | */ 145 | /** @type {CombinedResults} */ 146 | let combinedResults = {}; 147 | const results = await Promise.all(promiseArr); 148 | 149 | combinedResults = results.reduce((running, curr) => { 150 | const key = curr.fileMeta.resultKey || curr.fileMeta.fullFilePath; 151 | running[key] = curr.stamps; 152 | return running; 153 | }, combinedResults); 154 | 155 | return combinedResults; 156 | } 157 | 158 | module.exports = { 159 | updateTimestampsCacheFile, 160 | getTimestampsFromFilesBulk 161 | }; 162 | -------------------------------------------------------------------------------- /src/types.ts: -------------------------------------------------------------------------------- 1 | export type GitCommitHook = "pre" | "post" | "none"; 2 | 3 | export interface StampObject { 4 | // the stamp of when the file was created 5 | "created"?: number | boolean, 6 | // the stamp of when the file was modified 7 | "modified"?: number | boolean 8 | } 9 | 10 | export interface StampCache { 11 | [index:string]: StampObject 12 | } 13 | 14 | export interface InputOptions { 15 | // Whether or not the timestamps should be saved to file 16 | outputToFile?: boolean, 17 | // the filename to save the timestamps to 18 | outputFileName?: string, 19 | // If the output file should automatically be check-in with git add 20 | outputFileGitAdd?: boolean, 21 | // Filenames to process 22 | files?: string[] | string, 23 | // Only update for files in these directories 24 | onlyIn?: string[] | string, 25 | // Block certain files from being tracked 26 | blockFiles?: string[] | string, 27 | // Exception list of files that will override any blocks 28 | allowFiles?: string[] | string, 29 | // What triggered the execution 30 | gitCommitHook?: string, 31 | // Project root 32 | projectRootPath?: string, 33 | // Debug 34 | debug?: boolean 35 | } 36 | 37 | export interface FinalizedOptions { 38 | outputToFile: boolean, 39 | outputFileName?: string, 40 | outputFileGitAdd?: boolean, 41 | files: string[], 42 | onlyIn?: string[], 43 | blockFiles?: string[], 44 | allowFiles: string[], 45 | gitCommitHook: GitCommitHook, 46 | projectRootPath: string, 47 | projectRootPathTrailingSlash: string, 48 | debug: boolean 49 | } 50 | 51 | export interface DirListing { 52 | [index: string]: string | DirListing; 53 | } 54 | 55 | export type UnpackedPromise = T extends Promise ? U : T; 56 | -------------------------------------------------------------------------------- /tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "compilerOptions": { 3 | "allowJs": true, 4 | "checkJs": true, 5 | "declaration": true, 6 | "emitDeclarationOnly": false, 7 | "outDir": "./dist", 8 | "module": "commonjs", 9 | "moduleResolution": "node", 10 | "noImplicitAny": true, 11 | "esModuleInterop": true, 12 | "target": "es2018", 13 | "lib": ["es2018"], 14 | "newLine": "lf" 15 | }, 16 | "include": [ 17 | "*.d.ts", 18 | "src/**/*" 19 | ] 20 | } 21 | -------------------------------------------------------------------------------- /tst-helpers.js: -------------------------------------------------------------------------------- 1 | const childProc = require('child_process'); 2 | const fse = require('fs-extra'); 3 | const {replaceInObj, posixNormalize} = require('./src/helpers'); 4 | const os = require('os'); 5 | const {sep} = require('path'); 6 | 7 | /** 8 | * Test if the last commit in the log is from self (auto add of cache file) 9 | * @param {string} gitDir - Path where the commit was made 10 | * @param {string} cacheFileName - Filename (not path) of the cache file that was added 11 | * @returns {boolean} If commit was from self 12 | */ 13 | function wasLastCommitAutoAddCache(gitDir, cacheFileName) { 14 | try { 15 | const gitCommitMsg = childProc.execSync(`git show -s --format=%s`, { 16 | cwd: gitDir 17 | }).toString(); 18 | const changedFiles = childProc.execSync(`git show HEAD --name-only --format=%b`, { 19 | cwd: gitDir 20 | }).toString().trim(); 21 | const commitMsgMatch = /AUTO: Updated/.test(gitCommitMsg); 22 | const changedFilesMatch = changedFiles === cacheFileName; 23 | return commitMsgMatch && changedFilesMatch; 24 | // eslint-disable-next-line no-unused-vars 25 | } catch (error) { 26 | return false; 27 | } 28 | } 29 | 30 | /* istanbul ignore next */ 31 | /** 32 | * Log something to the console and (if configured) log file 33 | * @param {any} msg 34 | */ 35 | function iDebugLog(msg) { 36 | console.log(msg); 37 | if (typeof (msg) === 'object') { 38 | msg = JSON.stringify(msg); 39 | } 40 | msg = '\n' + msg; 41 | const debugLog = './debug.txt'; 42 | fse.exists(debugLog, function(exists) { 43 | if (exists) { 44 | fse.writeFileSync(debugLog, msg, { 45 | flag: 'a' 46 | }); 47 | } 48 | }); 49 | } 50 | 51 | /** 52 | * Get list of test files paths to use (not created) 53 | * @param {string} dirPath - The full path dir where temp files should go 54 | */ 55 | function getTestFilePaths(dirPath) { 56 | dirPath = posixNormalize(dirPath); 57 | const subDirName = 'subdir'; 58 | const dotDirName = '.dotdir'; 59 | return { 60 | alpha: `${dirPath}/alpha.txt`, 61 | bravo: `${dirPath}/bravo.txt`, 62 | charlie: `${dirPath}/charlie.txt`, 63 | space: `${dirPath}/space test.png`, 64 | specialChars: `${dirPath}/special-chars.file.gif`, 65 | subdir: { 66 | delta: `${dirPath}/${subDirName}/delta.txt`, 67 | echo: `${dirPath}/${subDirName}/echo.txt` 68 | }, 69 | [dotDirName]: { 70 | foxtrot: `${dirPath}/${dotDirName}/foxtrot.txt` 71 | } 72 | }; 73 | } 74 | 75 | async function makeTempDir() { 76 | return fse.mkdtemp(`${os.tmpdir()}${sep}`); 77 | } 78 | 79 | /** 80 | * Build a local directory, filled with dummy files 81 | * @param {string} dirPath - Absolute path of where the directory should be created 82 | * @param {import('./src/types').DirListing} dirListing - Listing of files to create, using absolute paths 83 | * @param {boolean} [empty] - Clear the directory out first, before building files 84 | */ 85 | async function buildDir(dirPath, dirListing, empty = false) { 86 | await fse.ensureDir(dirPath); 87 | if (empty) { 88 | await fse.emptyDir(dirPath); 89 | } 90 | /** 91 | * @param {string | import('./src/types').DirListing} pathOrObj 92 | * @returns {Promise} 93 | */ 94 | const recursingCreator = async (pathOrObj) => { 95 | if (typeof pathOrObj === 'string') { 96 | return fse.ensureFile(pathOrObj); 97 | } 98 | 99 | return Promise.all(Object.keys(pathOrObj).map(key => { 100 | const val = pathOrObj[key]; 101 | return recursingCreator(val); 102 | })); 103 | }; 104 | 105 | await recursingCreator(dirListing); 106 | } 107 | 108 | /** 109 | * Build a test dir based on inputs 110 | * @param {string} tempDirPath - The full path of the temp dir 111 | * @param {boolean} gitInit - if `git init` should be ran in dir 112 | * @param {string} [cacheFileName] - If cache file should be created, pass name 113 | */ 114 | async function buildTestDir(tempDirPath, gitInit, cacheFileName) { 115 | // Make sure tempDirPath does *not* end with slash 116 | tempDirPath = tempDirPath.replace(/[\/\\]+$/, ''); 117 | const testFiles = getTestFilePaths(tempDirPath); 118 | 119 | // Some pre-formatted different versions of the test file paths 120 | const testFilesRelative = /** @type {ReturnType} */ (replaceInObj(testFiles, filePath => { 121 | return posixNormalize(filePath).replace(posixNormalize(`${tempDirPath}/`), ''); 122 | })); 123 | const testFilesNamesOnly = /** @type {ReturnType} */ (replaceInObj(testFiles, filePath => { 124 | const filename = posixNormalize(filePath).replace(posixNormalize(tempDirPath), ''); 125 | // Remove any beginning slashes, and posix normalize 126 | return posixNormalize(filename.replace(/^[\/\\]{1,2}/g, '')); 127 | })); 128 | 129 | // Actually build the files 130 | await buildDir(tempDirPath, testFiles); 131 | 132 | // Create empty cache file 133 | if (typeof (cacheFileName) === 'string') { 134 | fse.ensureFileSync(`${tempDirPath}/${cacheFileName}`); 135 | } 136 | 137 | /* istanbul ignore else */ 138 | if (gitInit) { 139 | childProc.execSync(`git init`, { 140 | cwd: tempDirPath 141 | }); 142 | } 143 | 144 | return { 145 | testFiles, 146 | testFilesRelative, 147 | testFilesNamesOnly, 148 | stamp: Math.floor((new Date()).getTime() / 1000) 149 | }; 150 | } 151 | 152 | /** 153 | * Touch a file (change mtime and/or add text) 154 | * @param {string} filePath - File to "touch" 155 | * @param {boolean} byAppending - By appending text 156 | * @param {boolean} [OPT_useShell] - Use `touch` command 157 | * @returns {void} 158 | */ 159 | function touchFileSync(filePath, byAppending, OPT_useShell) { 160 | const useShell = typeof (OPT_useShell) === 'boolean' ? OPT_useShell : false; 161 | if (byAppending === true) { 162 | // Make sure to actually change file contents to trigger git 163 | fse.writeFileSync(filePath, 'TOUCHED', { 164 | flag: 'a' 165 | }); 166 | } 167 | else if (useShell) { 168 | childProc.execSync(`touch ${filePath} -m`); 169 | } 170 | else { 171 | const now = new Date(); 172 | try { 173 | fse.utimesSync(filePath, now, now); 174 | // eslint-disable-next-line no-unused-vars 175 | } catch (error) { 176 | fse.closeSync(fse.openSync(filePath, 'w')); 177 | } 178 | } 179 | } 180 | 181 | /** 182 | * Require that all input files have a corresponding stamp entry in results 183 | * @param {import('ava').ExecutionContext} testContext 184 | * @param {import('./src/types').DirListing} files - Input file list. Should be relative (or whatever matches `results` object keys format) 185 | * @param {import('./src/types').StampCache} results - Output results from scraper 186 | * @param {string[]} [skipFiles] Files to excludes from checking 187 | */ 188 | function testForStampInResults(testContext, files, results, skipFiles = []) { 189 | for (const key in files) { 190 | if (typeof files[key] === 'object') { 191 | /** @type {import('./src/types').DirListing} */ 192 | const dirListing = (files[key]); 193 | testForStampInResults(testContext, dirListing, results, skipFiles); 194 | } else { 195 | /** @type {string} */ 196 | const filePath = (files[key]); 197 | if (!skipFiles.includes(filePath)) { 198 | const stampEntry = results[filePath]; 199 | const testMsg = JSON.stringify({ 200 | filePath, 201 | skipFiles, 202 | stampEntry, 203 | results 204 | }, null, 4); 205 | testContext.true(typeof stampEntry === 'object', testMsg); 206 | testContext.true(typeof stampEntry.created === 'number', testMsg); 207 | testContext.true(typeof stampEntry.modified === 'number', testMsg); 208 | } 209 | } 210 | } 211 | } 212 | 213 | /** @param {number} delayMs */ 214 | const delay = (delayMs) => { 215 | return new Promise((resolve) => { 216 | setTimeout(() => { 217 | resolve(); 218 | }, delayMs); 219 | }); 220 | }; 221 | 222 | module.exports = { 223 | wasLastCommitAutoAddCache, 224 | iDebugLog, 225 | buildTestDir, 226 | touchFileSync, 227 | getTestFilePaths, 228 | buildDir, 229 | testForStampInResults, 230 | makeTempDir, 231 | delay 232 | }; 233 | --------------------------------------------------------------------------------