├── .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 | [](https://github.com/joshuatz/git-date-extractor/tree/main/.github/workflows/nodejs.yml)
3 | [](https://codecov.io/gh/joshuatz/git-date-extractor?branch=main)
4 | [](https://www.npmjs.com/package/git-date-extractor)
5 | [](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 | 
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 |
--------------------------------------------------------------------------------