git-er-done 0.1.11 → 0.1.13
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +100 -0
- package/package.json +55 -1
- package/src/git/dates/getFileDates.js +204 -0
- package/src/git/dates/getFileDates.test.js +203 -0
- package/src/git/getDiffFormatted.js +142 -0
- package/src/git/getFileAtCommit.js +27 -0
- package/src/git/localGetFileAtSHA.js +17 -6
- package/src/git/utils/differ.js +1 -0
- package/src/git/utils/gitignore.js +1 -0
- package/src/git/utils/term-size.js +136 -0
- package/src/git/utils/validateFilePath.js +44 -0
- package/src/index.js +23 -3
- package/src/types.js +8 -0
- package/types/git/dates/getFileDates.d.ts +82 -0
- package/types/git/getDiffFormatted.d.ts +20 -0
- package/types/git/getFileAtCommit.d.ts +17 -0
- package/types/git/localGetFileAtSHA.d.ts +12 -1
- package/types/git/utils/differ.d.ts +0 -0
- package/types/git/utils/gitignore.d.ts +0 -0
- package/types/git/utils/term-size.d.ts +15 -0
- package/types/git/utils/validateFilePath.d.ts +7 -0
- package/types/index.d.ts +10 -3
- package/types/types.d.ts +18 -0
package/README.md
CHANGED
|
@@ -147,6 +147,39 @@ if (mdFiles.edited) {
|
|
|
147
147
|
}
|
|
148
148
|
```
|
|
149
149
|
|
|
150
|
+
### Getting File Creation and Modification Dates
|
|
151
|
+
|
|
152
|
+
Get git timestamps for when files were created and last modified:
|
|
153
|
+
|
|
154
|
+
```js
|
|
155
|
+
const { getFileDates, getFileModifiedTimeStamp, getFileCreatedTimeStamp } = require('git-er-done')
|
|
156
|
+
|
|
157
|
+
// Get both dates efficiently (recommended)
|
|
158
|
+
const dates = await getFileDates('src/index.js')
|
|
159
|
+
console.log('Created:', dates.createdDate)
|
|
160
|
+
console.log('Modified:', dates.modifiedDate)
|
|
161
|
+
console.log('Created timestamp:', dates.created) // Unix timestamp in seconds
|
|
162
|
+
console.log('Modified timestamp:', dates.modified) // Unix timestamp in seconds
|
|
163
|
+
|
|
164
|
+
// Or get just modified date
|
|
165
|
+
const modifiedTimestamp = await getFileModifiedTimeStamp('README.md')
|
|
166
|
+
console.log('Last modified:', new Date(modifiedTimestamp * 1000))
|
|
167
|
+
|
|
168
|
+
// Or get just created date
|
|
169
|
+
const createdTimestamp = await getFileCreatedTimeStamp('README.md')
|
|
170
|
+
console.log('First committed:', new Date(createdTimestamp * 1000))
|
|
171
|
+
|
|
172
|
+
// Get dates for multiple files (pass an array)
|
|
173
|
+
const files = ['README.md', 'package.json', 'src/index.js']
|
|
174
|
+
const fileDates = await getFileDates(files)
|
|
175
|
+
|
|
176
|
+
for (const [file, info] of Object.entries(fileDates)) {
|
|
177
|
+
if (!info.error) {
|
|
178
|
+
console.log(`${file}: last modified ${info.modifiedDate.toISOString()}`)
|
|
179
|
+
}
|
|
180
|
+
}
|
|
181
|
+
```
|
|
182
|
+
|
|
150
183
|
### Getting Detailed File Information
|
|
151
184
|
|
|
152
185
|
The `fileMatch` function returns detailed information about matched files:
|
|
@@ -168,6 +201,70 @@ console.log('All edited test files:', testFiles.editedFiles)
|
|
|
168
201
|
if (testFiles.edited) {
|
|
169
202
|
console.log('Tests have been modified - run test suite')
|
|
170
203
|
}
|
|
204
|
+
```
|
|
205
|
+
|
|
206
|
+
### Getting Git Root Directory
|
|
207
|
+
|
|
208
|
+
```js
|
|
209
|
+
const { getGitRoot } = require('git-er-done')
|
|
210
|
+
|
|
211
|
+
const root = await getGitRoot()
|
|
212
|
+
console.log('Git root:', root) // '/Users/you/your-repo'
|
|
213
|
+
```
|
|
214
|
+
|
|
215
|
+
### Getting File Contents at a Specific Commit
|
|
216
|
+
|
|
217
|
+
Retrieve the contents of a file as it existed at a specific commit:
|
|
218
|
+
|
|
219
|
+
```js
|
|
220
|
+
const { getFileAtCommit } = require('git-er-done')
|
|
221
|
+
|
|
222
|
+
// Get file contents from previous commit
|
|
223
|
+
const previousVersion = await getFileAtCommit('src/index.js', 'HEAD~1')
|
|
224
|
+
console.log('Previous version:', previousVersion)
|
|
225
|
+
|
|
226
|
+
// Get file at specific SHA
|
|
227
|
+
const oldVersion = await getFileAtCommit('package.json', 'abc123')
|
|
228
|
+
|
|
229
|
+
// With cwd option for running in a different directory
|
|
230
|
+
const contents = await getFileAtCommit('src/app.js', 'main', {
|
|
231
|
+
cwd: '/path/to/other/repo'
|
|
232
|
+
})
|
|
233
|
+
```
|
|
234
|
+
|
|
235
|
+
### Getting Git Remotes
|
|
236
|
+
|
|
237
|
+
```js
|
|
238
|
+
const { getRemotes, getRemote } = require('git-er-done')
|
|
239
|
+
|
|
240
|
+
// Get all remotes
|
|
241
|
+
const remotes = await getRemotes()
|
|
242
|
+
console.log('All remotes:', Object.keys(remotes)) // ['origin', 'upstream']
|
|
243
|
+
console.log('Origin URL:', remotes.origin.url)
|
|
244
|
+
|
|
245
|
+
// Get a specific remote
|
|
246
|
+
const origin = await getRemote('origin')
|
|
247
|
+
console.log('Origin:', origin)
|
|
248
|
+
// { name: 'origin', url: 'git@github.com:user/repo.git', fetchUrl: '...', pushUrl: '...' }
|
|
249
|
+
```
|
|
250
|
+
|
|
251
|
+
### Subpath Exports
|
|
252
|
+
|
|
253
|
+
Import only what you need for smaller bundles:
|
|
254
|
+
|
|
255
|
+
```js
|
|
256
|
+
// Individual imports
|
|
257
|
+
const { getCommit } = require('git-er-done/get-commit')
|
|
258
|
+
const { getAllCommits } = require('git-er-done/get-all-commits')
|
|
259
|
+
const { getFirstCommit } = require('git-er-done/get-first-commit')
|
|
260
|
+
const { getLastCommit } = require('git-er-done/get-last-commit')
|
|
261
|
+
const { gitDetails } = require('git-er-done/get-details')
|
|
262
|
+
const { getGitRoot } = require('git-er-done/get-root')
|
|
263
|
+
const { getGitFiles } = require('git-er-done/get-files')
|
|
264
|
+
const { getFileAtCommit } = require('git-er-done/get-file-at-commit')
|
|
265
|
+
const { getFileDates, getFileModifiedTimeStamp, getFileCreatedTimeStamp } = require('git-er-done/get-file-dates')
|
|
266
|
+
const { getRemotes, getRemote } = require('git-er-done/get-remotes')
|
|
267
|
+
```
|
|
171
268
|
|
|
172
269
|
## Examples
|
|
173
270
|
|
|
@@ -178,6 +275,7 @@ Check out the [`examples`](./examples) directory for more use cases:
|
|
|
178
275
|
- [`get-all-commits.js`](./examples/get-all-commits.js) - Retrieve and display all commits
|
|
179
276
|
- [`get-git-files.js`](./examples/get-git-files.js) - Get list of all git-tracked files
|
|
180
277
|
- [`get-specific-commit-info.js`](./examples/get-specific-commit-info.js) - Get detailed information about a specific commit
|
|
278
|
+
- [`get-file-dates.js`](./examples/get-file-dates.js) - Get creation and modification dates for files
|
|
181
279
|
|
|
182
280
|
### File Change Detection
|
|
183
281
|
- [`detect-file-changes.js`](./examples/detect-file-changes.js) - Detect specific file changes between commits
|
|
@@ -191,6 +289,7 @@ Check out the [`examples`](./examples) directory for more use cases:
|
|
|
191
289
|
- [`check-config-changes.js`](./examples/check-config-changes.js) - Detect configuration file changes for CI/CD pipelines
|
|
192
290
|
- [`code-review-helper.js`](./examples/code-review-helper.js) - Automated code review checklist and suggestions
|
|
193
291
|
- [`monorepo-package-detection.js`](./examples/monorepo-package-detection.js) - Detect which packages changed in a monorepo
|
|
292
|
+
- [`serverless-monorepo-detection.js`](./examples/serverless-monorepo-detection.js) - Detect changes in serverless projects within a monorepo
|
|
194
293
|
- [`generate-release-notes.js`](./examples/generate-release-notes.js) - Auto-generate release notes from commits
|
|
195
294
|
|
|
196
295
|
Run any example:
|
|
@@ -199,6 +298,7 @@ Run any example:
|
|
|
199
298
|
node examples/get-git-data.js
|
|
200
299
|
node examples/code-review-helper.js
|
|
201
300
|
node examples/monorepo-package-detection.js
|
|
301
|
+
node examples/serverless-monorepo-detection.js
|
|
202
302
|
```
|
|
203
303
|
|
|
204
304
|
## Prior art
|
package/package.json
CHANGED
|
@@ -1,9 +1,59 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "git-er-done",
|
|
3
|
-
"version": "0.1.
|
|
3
|
+
"version": "0.1.13",
|
|
4
4
|
"description": "Utility for dealing with modified, created, deleted files since a git commit",
|
|
5
5
|
"main": "src/index.js",
|
|
6
6
|
"types": "types/index.d.ts",
|
|
7
|
+
"exports": {
|
|
8
|
+
".": {
|
|
9
|
+
"types": "./types/index.d.ts",
|
|
10
|
+
"default": "./src/index.js"
|
|
11
|
+
},
|
|
12
|
+
"./get-commit": {
|
|
13
|
+
"types": "./types/git/commits/getCommit.d.ts",
|
|
14
|
+
"default": "./src/git/commits/getCommit.js"
|
|
15
|
+
},
|
|
16
|
+
"./get-all-commits": {
|
|
17
|
+
"types": "./types/git/commits/getAllCommits.d.ts",
|
|
18
|
+
"default": "./src/git/commits/getAllCommits.js"
|
|
19
|
+
},
|
|
20
|
+
"./get-first-commit": {
|
|
21
|
+
"types": "./types/git/commits/getFirstCommit.d.ts",
|
|
22
|
+
"default": "./src/git/commits/getFirstCommit.js"
|
|
23
|
+
},
|
|
24
|
+
"./get-last-commit": {
|
|
25
|
+
"types": "./types/git/commits/getLastCommit.d.ts",
|
|
26
|
+
"default": "./src/git/commits/getLastCommit.js"
|
|
27
|
+
},
|
|
28
|
+
"./get-details": {
|
|
29
|
+
"types": "./types/git/getDetails.d.ts",
|
|
30
|
+
"default": "./src/git/getDetails.js"
|
|
31
|
+
},
|
|
32
|
+
"./get-root": {
|
|
33
|
+
"types": "./types/git/getGitRoot.d.ts",
|
|
34
|
+
"default": "./src/git/getGitRoot.js"
|
|
35
|
+
},
|
|
36
|
+
"./get-files": {
|
|
37
|
+
"types": "./types/git/getGitFiles.d.ts",
|
|
38
|
+
"default": "./src/git/getGitFiles.js"
|
|
39
|
+
},
|
|
40
|
+
"./get-file-at-commit": {
|
|
41
|
+
"types": "./types/git/getFileAtCommit.d.ts",
|
|
42
|
+
"default": "./src/git/getFileAtCommit.js"
|
|
43
|
+
},
|
|
44
|
+
"./get-diff-formatted": {
|
|
45
|
+
"types": "./types/git/getDiffFormatted.d.ts",
|
|
46
|
+
"default": "./src/git/getDiffFormatted.js"
|
|
47
|
+
},
|
|
48
|
+
"./get-file-dates": {
|
|
49
|
+
"types": "./types/git/dates/getFileDates.d.ts",
|
|
50
|
+
"default": "./src/git/dates/getFileDates.js"
|
|
51
|
+
},
|
|
52
|
+
"./get-remotes": {
|
|
53
|
+
"types": "./types/git/remotes/getRemotes.d.ts",
|
|
54
|
+
"default": "./src/git/remotes/getRemotes.js"
|
|
55
|
+
}
|
|
56
|
+
},
|
|
7
57
|
"scripts": {
|
|
8
58
|
"test": "uvu . \\.test\\.js$",
|
|
9
59
|
"check": "tsc --noEmit",
|
|
@@ -40,7 +90,11 @@
|
|
|
40
90
|
"url": "https://github.com/DavidWells/components/tree/master/packages/util-git-info"
|
|
41
91
|
},
|
|
42
92
|
"devDependencies": {
|
|
93
|
+
"@davidwells/extract-deps": "^0.0.5",
|
|
94
|
+
"@davidwells/git-split-diffs": "^2.2.1",
|
|
43
95
|
"@types/node": "^22.10.1",
|
|
96
|
+
"configorama": "^0.6.14",
|
|
97
|
+
"minimatch": "^10.0.1",
|
|
44
98
|
"typescript": "^5.7.2",
|
|
45
99
|
"uvu": "^0.5.6"
|
|
46
100
|
}
|
|
@@ -0,0 +1,204 @@
|
|
|
1
|
+
const { executeCommand } = require('../utils/exec')
|
|
2
|
+
const path = require('path')
|
|
3
|
+
const { getGitRoot } = require('../getGitRoot')
|
|
4
|
+
const { validateFilePath } = require('../utils/validateFilePath')
|
|
5
|
+
|
|
6
|
+
/**
|
|
7
|
+
* @typedef {Object} FileDateInfo
|
|
8
|
+
* @property {number} created - Unix timestamp (seconds) of when the file was first committed
|
|
9
|
+
* @property {number} modified - Unix timestamp (seconds) of when the file was last modified
|
|
10
|
+
* @property {Date} createdDate - JavaScript Date object of creation time
|
|
11
|
+
* @property {Date} modifiedDate - JavaScript Date object of last modification
|
|
12
|
+
*/
|
|
13
|
+
|
|
14
|
+
/**
|
|
15
|
+
* @typedef {Object} FileDateError
|
|
16
|
+
* @property {string} error - Error message when file date retrieval fails
|
|
17
|
+
*/
|
|
18
|
+
|
|
19
|
+
/**
|
|
20
|
+
* Gets the last modification date of a file from git history
|
|
21
|
+
* @param {string} filePath - Path to the file (absolute or relative to git root)
|
|
22
|
+
* @param {Object} [options] - Options
|
|
23
|
+
* @param {string} [options.cwd] - Current working directory (defaults to process.cwd())
|
|
24
|
+
* @returns {Promise<number>} Promise that resolves to Unix timestamp (seconds) of last modification
|
|
25
|
+
* @example
|
|
26
|
+
* const modifiedDate = await getFileModifiedTimeStamp('src/index.js')
|
|
27
|
+
* console.log('Last modified:', new Date(modifiedDate * 1000))
|
|
28
|
+
*/
|
|
29
|
+
async function getFileModifiedTimeStamp(filePath, options = {}) {
|
|
30
|
+
const cwd = options.cwd || process.cwd()
|
|
31
|
+
|
|
32
|
+
return new Promise((resolve, reject) => {
|
|
33
|
+
// Validate file path to prevent command injection
|
|
34
|
+
try {
|
|
35
|
+
validateFilePath(filePath)
|
|
36
|
+
} catch (err) {
|
|
37
|
+
return reject(err)
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
// Get most recent commit timestamp for the file
|
|
41
|
+
const command = `git log -1 --pretty=format:%at --follow -- "${filePath}"`
|
|
42
|
+
|
|
43
|
+
executeCommand(command, { dst: cwd }, (err, stdout) => {
|
|
44
|
+
if (err) {
|
|
45
|
+
return reject(new Error(`Failed to get modified date for ${filePath}: ${err.message}`))
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
const timestamp = stdout.trim()
|
|
49
|
+
if (!timestamp || timestamp === '') {
|
|
50
|
+
// File might not be committed yet
|
|
51
|
+
return reject(new Error(`No git history found for ${filePath}`))
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
const unixTimestamp = parseInt(timestamp, 10)
|
|
55
|
+
if (isNaN(unixTimestamp)) {
|
|
56
|
+
return reject(new Error(`Invalid timestamp received for ${filePath}`))
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
resolve(unixTimestamp)
|
|
60
|
+
})
|
|
61
|
+
})
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
/**
|
|
65
|
+
* Gets the creation date of a file from git history (first commit)
|
|
66
|
+
* @param {string} filePath - Path to the file (absolute or relative to git root)
|
|
67
|
+
* @param {Object} [options] - Options
|
|
68
|
+
* @param {string} [options.cwd] - Current working directory (defaults to process.cwd())
|
|
69
|
+
* @returns {Promise<number>} Promise that resolves to Unix timestamp (seconds) of creation
|
|
70
|
+
* @example
|
|
71
|
+
* const createdDate = await getFileCreatedTimeStamp('src/index.js')
|
|
72
|
+
* console.log('Created:', new Date(createdDate * 1000))
|
|
73
|
+
*/
|
|
74
|
+
async function getFileCreatedTimeStamp(filePath, options = {}) {
|
|
75
|
+
const cwd = options.cwd || process.cwd()
|
|
76
|
+
|
|
77
|
+
return new Promise((resolve, reject) => {
|
|
78
|
+
// Validate file path to prevent command injection
|
|
79
|
+
try {
|
|
80
|
+
validateFilePath(filePath)
|
|
81
|
+
} catch (err) {
|
|
82
|
+
return reject(err)
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
// Get all commit timestamps following history, then take the last one (oldest)
|
|
86
|
+
const command = `git log --follow --pretty=format:%at -- "${filePath}"`
|
|
87
|
+
|
|
88
|
+
executeCommand(command, { dst: cwd }, (err, stdout) => {
|
|
89
|
+
if (err) {
|
|
90
|
+
return reject(new Error(`Failed to get created date for ${filePath}: ${err.message}`))
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
const timestamps = stdout.trim().split('\n').filter(Boolean)
|
|
94
|
+
if (timestamps.length === 0) {
|
|
95
|
+
return reject(new Error(`No git history found for ${filePath}`))
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
// Last timestamp in the log is the oldest (creation)
|
|
99
|
+
const createdTimestamp = timestamps[timestamps.length - 1]
|
|
100
|
+
const unixTimestamp = parseInt(createdTimestamp, 10)
|
|
101
|
+
|
|
102
|
+
if (isNaN(unixTimestamp)) {
|
|
103
|
+
return reject(new Error(`Invalid timestamp received for ${filePath}`))
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
resolve(unixTimestamp)
|
|
107
|
+
})
|
|
108
|
+
})
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
/**
|
|
112
|
+
* Gets dates for a single file (internal helper)
|
|
113
|
+
* @param {string} filePath - Path to the file
|
|
114
|
+
* @param {Object} [options] - Options
|
|
115
|
+
* @param {string} [options.cwd] - Current working directory
|
|
116
|
+
* @returns {Promise<FileDateInfo>}
|
|
117
|
+
*/
|
|
118
|
+
async function getSingleFileDates(filePath, options = {}) {
|
|
119
|
+
const cwd = options.cwd || process.cwd()
|
|
120
|
+
|
|
121
|
+
return new Promise((resolve, reject) => {
|
|
122
|
+
// Validate file path to prevent command injection
|
|
123
|
+
try {
|
|
124
|
+
validateFilePath(filePath)
|
|
125
|
+
} catch (err) {
|
|
126
|
+
return reject(err)
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
// Get all commit timestamps following history
|
|
130
|
+
const command = `git log --follow --pretty=format:%at -- "${filePath}"`
|
|
131
|
+
|
|
132
|
+
executeCommand(command, { dst: cwd }, (err, stdout) => {
|
|
133
|
+
if (err) {
|
|
134
|
+
return reject(new Error(`Failed to get dates for ${filePath}: ${err.message}`))
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
const timestamps = stdout.trim().split('\n').filter(Boolean)
|
|
138
|
+
if (timestamps.length === 0) {
|
|
139
|
+
return reject(new Error(`No git history found for ${filePath}`))
|
|
140
|
+
}
|
|
141
|
+
|
|
142
|
+
// First timestamp is most recent (modified), last is oldest (created)
|
|
143
|
+
const modifiedTimestamp = parseInt(timestamps[0], 10)
|
|
144
|
+
const createdTimestamp = parseInt(timestamps[timestamps.length - 1], 10)
|
|
145
|
+
|
|
146
|
+
if (isNaN(modifiedTimestamp) || isNaN(createdTimestamp)) {
|
|
147
|
+
return reject(new Error(`Invalid timestamp received for ${filePath}`))
|
|
148
|
+
}
|
|
149
|
+
|
|
150
|
+
resolve({
|
|
151
|
+
created: createdTimestamp,
|
|
152
|
+
modified: modifiedTimestamp,
|
|
153
|
+
createdDate: new Date(createdTimestamp * 1000),
|
|
154
|
+
modifiedDate: new Date(modifiedTimestamp * 1000)
|
|
155
|
+
})
|
|
156
|
+
})
|
|
157
|
+
})
|
|
158
|
+
}
|
|
159
|
+
|
|
160
|
+
/**
|
|
161
|
+
* Gets both creation and modification dates for file(s) from git history
|
|
162
|
+
* @param {string | string[]} filePaths - Path or array of paths to file(s)
|
|
163
|
+
* @param {Object} [options] - Options
|
|
164
|
+
* @param {string} [options.cwd] - Current working directory (defaults to process.cwd())
|
|
165
|
+
* @returns {Promise<FileDateInfo | Object<string, FileDateInfo | FileDateError>>} Single file returns FileDateInfo, multiple files returns object keyed by path
|
|
166
|
+
* @example
|
|
167
|
+
* // Single file
|
|
168
|
+
* const dates = await getFileDates('src/index.js')
|
|
169
|
+
* console.log('Created:', dates.createdDate)
|
|
170
|
+
* console.log('Modified:', dates.modifiedDate)
|
|
171
|
+
*
|
|
172
|
+
* // Multiple files
|
|
173
|
+
* const dates = await getFileDates(['src/index.js', 'README.md'])
|
|
174
|
+
* console.log('Index modified:', dates['src/index.js'].modifiedDate)
|
|
175
|
+
*/
|
|
176
|
+
async function getFileDates(filePaths, options = {}) {
|
|
177
|
+
// Single file path
|
|
178
|
+
if (typeof filePaths === 'string') {
|
|
179
|
+
return getSingleFileDates(filePaths, options)
|
|
180
|
+
}
|
|
181
|
+
|
|
182
|
+
// Multiple file paths
|
|
183
|
+
/** @type {Object<string, FileDateInfo | FileDateError>} */
|
|
184
|
+
const results = {}
|
|
185
|
+
|
|
186
|
+
await Promise.all(
|
|
187
|
+
filePaths.map(async (filePath) => {
|
|
188
|
+
try {
|
|
189
|
+
results[filePath] = await getSingleFileDates(filePath, options)
|
|
190
|
+
} catch (err) {
|
|
191
|
+
// Store error but don't fail entire operation
|
|
192
|
+
results[filePath] = { error: err.message }
|
|
193
|
+
}
|
|
194
|
+
})
|
|
195
|
+
)
|
|
196
|
+
|
|
197
|
+
return results
|
|
198
|
+
}
|
|
199
|
+
|
|
200
|
+
module.exports = {
|
|
201
|
+
getFileModifiedTimeStamp,
|
|
202
|
+
getFileCreatedTimeStamp,
|
|
203
|
+
getFileDates
|
|
204
|
+
}
|
|
@@ -0,0 +1,203 @@
|
|
|
1
|
+
const path = require('path')
|
|
2
|
+
const { test } = require('uvu')
|
|
3
|
+
const assert = require('uvu/assert')
|
|
4
|
+
|
|
5
|
+
const {
|
|
6
|
+
getFileModifiedTimeStamp,
|
|
7
|
+
getFileCreatedTimeStamp,
|
|
8
|
+
getFileDates
|
|
9
|
+
} = require('./getFileDates')
|
|
10
|
+
|
|
11
|
+
// Use style-guard package files for testing (they're unlikely to change)
|
|
12
|
+
// Note: These paths are relative to the git root
|
|
13
|
+
const STYLE_GUARD_INDEX = path.join(__dirname, '../../../../style-guard/index.js')
|
|
14
|
+
const STYLE_GUARD_README = path.join(__dirname, '../../../../style-guard/README.md')
|
|
15
|
+
const STYLE_GUARD_PACKAGE = path.join(__dirname, '../../../../style-guard/package.json')
|
|
16
|
+
|
|
17
|
+
// Wrapper to add timing to tests
|
|
18
|
+
const timedTest = (name, fn) => {
|
|
19
|
+
test(name, async (context) => {
|
|
20
|
+
const start = Date.now()
|
|
21
|
+
try {
|
|
22
|
+
await fn(context)
|
|
23
|
+
} finally {
|
|
24
|
+
const duration = Date.now() - start
|
|
25
|
+
console.log(` ⏱️ ${name}: ${duration}ms`)
|
|
26
|
+
}
|
|
27
|
+
})
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
timedTest('getFileModifiedTimeStamp should return a unix timestamp', async () => {
|
|
31
|
+
const timestamp = await getFileModifiedTimeStamp(STYLE_GUARD_INDEX)
|
|
32
|
+
assert.ok(Number.isInteger(timestamp))
|
|
33
|
+
assert.ok(timestamp > 0)
|
|
34
|
+
// Should be a reasonable date (after 2020)
|
|
35
|
+
assert.ok(timestamp > 1577836800)
|
|
36
|
+
})
|
|
37
|
+
|
|
38
|
+
timedTest('getFileCreatedTimeStamp should return a unix timestamp', async () => {
|
|
39
|
+
const timestamp = await getFileCreatedTimeStamp(STYLE_GUARD_INDEX)
|
|
40
|
+
assert.ok(Number.isInteger(timestamp))
|
|
41
|
+
assert.ok(timestamp > 0)
|
|
42
|
+
// Should be a reasonable date (after 2020)
|
|
43
|
+
assert.ok(timestamp > 1577836800)
|
|
44
|
+
})
|
|
45
|
+
|
|
46
|
+
timedTest('getFileDates should return both created and modified dates', async () => {
|
|
47
|
+
const dates = await getFileDates(STYLE_GUARD_INDEX)
|
|
48
|
+
console.log('getFileDates', dates)
|
|
49
|
+
|
|
50
|
+
// Check specific timestamp values
|
|
51
|
+
assert.is(dates.created, 1609635897)
|
|
52
|
+
assert.is(dates.modified, 1610136724)
|
|
53
|
+
|
|
54
|
+
// Check specific Date object values
|
|
55
|
+
assert.is(dates.createdDate.toISOString(), '2021-01-03T01:04:57.000Z')
|
|
56
|
+
assert.is(dates.modifiedDate.toISOString(), '2021-01-08T20:12:04.000Z')
|
|
57
|
+
|
|
58
|
+
// Check timestamps exist and are valid
|
|
59
|
+
assert.ok(Number.isInteger(dates.created))
|
|
60
|
+
assert.ok(Number.isInteger(dates.modified))
|
|
61
|
+
assert.ok(dates.created > 0)
|
|
62
|
+
assert.ok(dates.modified > 0)
|
|
63
|
+
|
|
64
|
+
// Check Date objects exist and are valid
|
|
65
|
+
assert.ok(dates.createdDate instanceof Date)
|
|
66
|
+
assert.ok(dates.modifiedDate instanceof Date)
|
|
67
|
+
|
|
68
|
+
// Created date should be before or equal to modified date
|
|
69
|
+
assert.ok(dates.created <= dates.modified)
|
|
70
|
+
|
|
71
|
+
// Date objects should match timestamps
|
|
72
|
+
assert.is(dates.createdDate.getTime(), dates.created * 1000)
|
|
73
|
+
assert.is(dates.modifiedDate.getTime(), dates.modified * 1000)
|
|
74
|
+
})
|
|
75
|
+
|
|
76
|
+
timedTest('getFileDates should return dates for multiple files', async () => {
|
|
77
|
+
const files = [STYLE_GUARD_INDEX, STYLE_GUARD_README, STYLE_GUARD_PACKAGE]
|
|
78
|
+
const results = await getFileDates(files)
|
|
79
|
+
|
|
80
|
+
console.log('getFileDates (multiple)', results)
|
|
81
|
+
|
|
82
|
+
// Should have results for all files
|
|
83
|
+
assert.equal(Object.keys(results).sort(), files.sort())
|
|
84
|
+
|
|
85
|
+
// Each file should have valid date info
|
|
86
|
+
for (const file of files) {
|
|
87
|
+
const dates = results[file]
|
|
88
|
+
|
|
89
|
+
// Skip if there was an error
|
|
90
|
+
if (dates.error) {
|
|
91
|
+
continue
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
assert.ok(Number.isInteger(dates.created))
|
|
95
|
+
assert.ok(Number.isInteger(dates.modified))
|
|
96
|
+
assert.ok(dates.createdDate instanceof Date)
|
|
97
|
+
assert.ok(dates.modifiedDate instanceof Date)
|
|
98
|
+
}
|
|
99
|
+
})
|
|
100
|
+
|
|
101
|
+
timedTest('should reject invalid file paths - null bytes', async () => {
|
|
102
|
+
try {
|
|
103
|
+
await getFileModifiedTimeStamp('test\0.js')
|
|
104
|
+
assert.unreachable('Should have thrown an error')
|
|
105
|
+
} catch (err) {
|
|
106
|
+
assert.match(err.message, /null bytes/)
|
|
107
|
+
}
|
|
108
|
+
})
|
|
109
|
+
|
|
110
|
+
timedTest('should reject invalid file paths - command injection', async () => {
|
|
111
|
+
try {
|
|
112
|
+
await getFileModifiedTimeStamp('test.js; rm -rf /')
|
|
113
|
+
assert.unreachable('Should have thrown an error')
|
|
114
|
+
} catch (err) {
|
|
115
|
+
assert.match(err.message, /suspicious characters/)
|
|
116
|
+
}
|
|
117
|
+
})
|
|
118
|
+
|
|
119
|
+
timedTest('should reject invalid file paths - shell metacharacters', async () => {
|
|
120
|
+
const dangerousPaths = [
|
|
121
|
+
'test.js|cat /etc/passwd',
|
|
122
|
+
'test.js`whoami`',
|
|
123
|
+
'test.js$(whoami)',
|
|
124
|
+
'test.js&& echo hacked',
|
|
125
|
+
]
|
|
126
|
+
|
|
127
|
+
for (const badPath of dangerousPaths) {
|
|
128
|
+
try {
|
|
129
|
+
await getFileModifiedTimeStamp(badPath)
|
|
130
|
+
assert.unreachable(`Should have thrown an error for: ${badPath}`)
|
|
131
|
+
} catch (err) {
|
|
132
|
+
assert.match(err.message, /suspicious characters/)
|
|
133
|
+
}
|
|
134
|
+
}
|
|
135
|
+
})
|
|
136
|
+
|
|
137
|
+
timedTest('should reject path traversal attempts', async () => {
|
|
138
|
+
try {
|
|
139
|
+
await getFileModifiedTimeStamp('../../etc/passwd')
|
|
140
|
+
assert.unreachable('Should have thrown an error')
|
|
141
|
+
} catch (err) {
|
|
142
|
+
assert.match(err.message, /suspicious characters|directory traversal/)
|
|
143
|
+
}
|
|
144
|
+
})
|
|
145
|
+
|
|
146
|
+
timedTest('should reject newlines in file paths', async () => {
|
|
147
|
+
try {
|
|
148
|
+
await getFileModifiedTimeStamp('test.js\nrm -rf /')
|
|
149
|
+
assert.unreachable('Should have thrown an error')
|
|
150
|
+
} catch (err) {
|
|
151
|
+
assert.match(err.message, /suspicious characters/)
|
|
152
|
+
}
|
|
153
|
+
})
|
|
154
|
+
|
|
155
|
+
timedTest('should reject empty or non-string file paths', async () => {
|
|
156
|
+
try {
|
|
157
|
+
await getFileModifiedTimeStamp('')
|
|
158
|
+
assert.unreachable('Should have thrown an error')
|
|
159
|
+
} catch (err) {
|
|
160
|
+
assert.match(err.message, /non-empty string/)
|
|
161
|
+
}
|
|
162
|
+
|
|
163
|
+
try {
|
|
164
|
+
await getFileModifiedTimeStamp(null)
|
|
165
|
+
assert.unreachable('Should have thrown an error')
|
|
166
|
+
} catch (err) {
|
|
167
|
+
assert.match(err.message, /non-empty string/)
|
|
168
|
+
}
|
|
169
|
+
|
|
170
|
+
try {
|
|
171
|
+
await getFileModifiedTimeStamp(undefined)
|
|
172
|
+
assert.unreachable('Should have thrown an error')
|
|
173
|
+
} catch (err) {
|
|
174
|
+
assert.match(err.message, /non-empty string/)
|
|
175
|
+
}
|
|
176
|
+
})
|
|
177
|
+
|
|
178
|
+
timedTest('should handle files with spaces in names', async () => {
|
|
179
|
+
// This should NOT be rejected - spaces are valid in file names
|
|
180
|
+
// Note: This test will fail if the file doesn't exist, but that's expected
|
|
181
|
+
// The important thing is that it doesn't fail due to path validation
|
|
182
|
+
try {
|
|
183
|
+
await getFileModifiedTimeStamp('test file.js')
|
|
184
|
+
} catch (err) {
|
|
185
|
+
// Should fail because file doesn't exist, not because of validation
|
|
186
|
+
assert.match(err.message, /No git history found|Failed to get/)
|
|
187
|
+
}
|
|
188
|
+
})
|
|
189
|
+
|
|
190
|
+
timedTest('should handle relative paths correctly', async () => {
|
|
191
|
+
// Relative paths should work - test with the same file
|
|
192
|
+
const timestamp = await getFileModifiedTimeStamp(STYLE_GUARD_INDEX)
|
|
193
|
+
assert.ok(Number.isInteger(timestamp))
|
|
194
|
+
assert.ok(timestamp > 0)
|
|
195
|
+
})
|
|
196
|
+
|
|
197
|
+
timedTest('created date should be less than or equal to modified date', async () => {
|
|
198
|
+
const dates = await getFileDates(STYLE_GUARD_INDEX)
|
|
199
|
+
assert.ok(dates.created <= dates.modified,
|
|
200
|
+
`Created date (${dates.created}) should be <= modified date (${dates.modified})`)
|
|
201
|
+
})
|
|
202
|
+
|
|
203
|
+
test.run()
|
|
@@ -0,0 +1,142 @@
|
|
|
1
|
+
const path = require('path')
|
|
2
|
+
const { promisify } = require('util')
|
|
3
|
+
const { exec } = require('child_process')
|
|
4
|
+
const { getGitRoot } = require('./getGitRoot')
|
|
5
|
+
const terminalSize = require('./utils/term-size')
|
|
6
|
+
const execAsync = promisify(exec)
|
|
7
|
+
|
|
8
|
+
/**
|
|
9
|
+
* Strip ANSI escape codes from a string
|
|
10
|
+
*/
|
|
11
|
+
function stripAnsi(str) {
|
|
12
|
+
return str.replace(/\x1b\[[0-9;]*m/g, '')
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
/**
|
|
16
|
+
* Calculate the longest line length in a diff
|
|
17
|
+
*/
|
|
18
|
+
function getLongestLineLength(diff) {
|
|
19
|
+
const lines = diff.split('\n')
|
|
20
|
+
let maxLength = 0
|
|
21
|
+
|
|
22
|
+
for (const line of lines) {
|
|
23
|
+
// Skip diff headers
|
|
24
|
+
if (line.startsWith('diff --git') ||
|
|
25
|
+
line.startsWith('index ') ||
|
|
26
|
+
line.startsWith('---') ||
|
|
27
|
+
line.startsWith('+++')) {
|
|
28
|
+
continue
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
let contentToMeasure = line
|
|
32
|
+
|
|
33
|
+
// For hunk headers (@@), extract the code context after the second @@
|
|
34
|
+
if (line.startsWith('@@')) {
|
|
35
|
+
const secondAtIndex = line.indexOf('@@', 2)
|
|
36
|
+
if (secondAtIndex !== -1) {
|
|
37
|
+
contentToMeasure = line.slice(secondAtIndex + 2)
|
|
38
|
+
}
|
|
39
|
+
} else {
|
|
40
|
+
// Get actual content (skip the leading space/+/-)
|
|
41
|
+
contentToMeasure = line.slice(1)
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
const cleanContent = stripAnsi(contentToMeasure)
|
|
45
|
+
|
|
46
|
+
if (cleanContent.length > maxLength) {
|
|
47
|
+
maxLength = cleanContent.length
|
|
48
|
+
}
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
return maxLength
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
/**
|
|
55
|
+
* Get formatted diff for a specific file
|
|
56
|
+
* @param {Object} options - Options for getting formatted diff
|
|
57
|
+
* @param {string} options.filePath - Relative path from git root
|
|
58
|
+
* @param {string} options.gitRootDir - Absolute path to git root directory
|
|
59
|
+
* @param {string} options.baseBranch - Base branch to compare against
|
|
60
|
+
* @param {boolean} options.shrinkToLongestLine - Auto-calculate width based on longest line
|
|
61
|
+
* @param {number} options.leftMargin - Number of spaces to add to the left of each line
|
|
62
|
+
* @param {number} options.width - Width of the diff output (ignored if shrinkToLongestLine is true)
|
|
63
|
+
* @param {boolean} options.hideHeader - Remove the file path header from the diff
|
|
64
|
+
*/
|
|
65
|
+
async function getFormattedDiff({
|
|
66
|
+
filePath,
|
|
67
|
+
gitRootDir,
|
|
68
|
+
baseBranch = 'master',
|
|
69
|
+
shrinkToLongestLine = false,
|
|
70
|
+
leftMargin = 0,
|
|
71
|
+
width = 140,
|
|
72
|
+
hideHeader = false
|
|
73
|
+
}) {
|
|
74
|
+
try {
|
|
75
|
+
const { formatDiff } = await import('@davidwells/git-split-diffs')
|
|
76
|
+
|
|
77
|
+
if (!gitRootDir) {
|
|
78
|
+
gitRootDir = await getGitRoot()
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
// Get the diff for this specific file
|
|
82
|
+
const { stdout: diff } = await execAsync(`git diff ${baseBranch} --ignore-cr-at-eol -- "${filePath}"`, {
|
|
83
|
+
cwd: gitRootDir
|
|
84
|
+
})
|
|
85
|
+
|
|
86
|
+
if (!diff) {
|
|
87
|
+
return null
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
// Calculate width if shrinkToLongestLine is enabled
|
|
91
|
+
let diffWidth = width
|
|
92
|
+
if (shrinkToLongestLine) {
|
|
93
|
+
const longestLine = getLongestLineLength(diff)
|
|
94
|
+
diffWidth = longestLine + 25
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
// TODO cache this value
|
|
98
|
+
const terminalWidth = terminalSize()
|
|
99
|
+
let maxWidth = Math.min(terminalWidth.columns, diffWidth)
|
|
100
|
+
|
|
101
|
+
if (leftMargin > 0) {
|
|
102
|
+
maxWidth = maxWidth - leftMargin
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
// Format the diff with git-split-diffs
|
|
106
|
+
let formatted = await formatDiff(diff, {
|
|
107
|
+
// hyperlinkFileNames: false,
|
|
108
|
+
// hyperlinkLineNumbers: false,
|
|
109
|
+
// hideFileHeader: true,
|
|
110
|
+
// omitHunkHeaders: true,
|
|
111
|
+
trimLastEmptyLine: true,
|
|
112
|
+
width: maxWidth,
|
|
113
|
+
minLineWidth: 80,
|
|
114
|
+
wrapLines: false,
|
|
115
|
+
highlightLineChanges: true,
|
|
116
|
+
themeName: 'dark',
|
|
117
|
+
gitRootDir,
|
|
118
|
+
})
|
|
119
|
+
|
|
120
|
+
// Remove header if hideHeader is true
|
|
121
|
+
if (hideHeader) {
|
|
122
|
+
const lines = formatted.split('\n')
|
|
123
|
+
// Skip first 3 lines (separator, file path with ■■, separator)
|
|
124
|
+
formatted = lines.slice(3).join('\n')
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
// Add left margin if specified
|
|
128
|
+
if (leftMargin > 0) {
|
|
129
|
+
const margin = ' '.repeat(leftMargin)
|
|
130
|
+
return formatted.split('\n').map(line => margin + line).join('\n')
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
return formatted
|
|
134
|
+
} catch (err) {
|
|
135
|
+
console.error(`Error formatting diff for ${filePath}:`, err.message)
|
|
136
|
+
return null
|
|
137
|
+
}
|
|
138
|
+
}
|
|
139
|
+
|
|
140
|
+
module.exports = {
|
|
141
|
+
getFormattedDiff
|
|
142
|
+
}
|
|
@@ -0,0 +1,27 @@
|
|
|
1
|
+
// Returns file contents at a specific git commit
|
|
2
|
+
const { localGetFileAtSHA } = require('./localGetFileAtSHA')
|
|
3
|
+
const { validateFilePath } = require('./utils/validateFilePath')
|
|
4
|
+
|
|
5
|
+
/**
|
|
6
|
+
* Gets the contents of a file at a specific git commit
|
|
7
|
+
* @param {string} filePath - Path to the file (relative to git root)
|
|
8
|
+
* @param {string} sha - Git commit SHA or ref (e.g., 'HEAD', 'main', 'abc123')
|
|
9
|
+
* @param {Object} [options] - Options
|
|
10
|
+
* @param {string} [options.cwd] - Working directory (defaults to process.cwd())
|
|
11
|
+
* @returns {Promise<string|undefined>} File contents at that commit, or undefined if not found
|
|
12
|
+
* @example
|
|
13
|
+
* const contents = await getFileAtCommit('src/index.js', 'HEAD~1')
|
|
14
|
+
* console.log('Previous version:', contents)
|
|
15
|
+
*
|
|
16
|
+
* // With cwd option
|
|
17
|
+
* const contents = await getFileAtCommit('package.json', 'HEAD', { cwd: '/path/to/repo' })
|
|
18
|
+
*/
|
|
19
|
+
async function getFileAtCommit(filePath, sha, options = {}) {
|
|
20
|
+
validateFilePath(filePath)
|
|
21
|
+
if (!sha || typeof sha !== 'string') {
|
|
22
|
+
throw new Error('SHA must be a non-empty string')
|
|
23
|
+
}
|
|
24
|
+
return localGetFileAtSHA(filePath, null, sha, options)
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
module.exports = { getFileAtCommit }
|
|
@@ -1,21 +1,32 @@
|
|
|
1
|
+
// Gets file contents at a specific git SHA
|
|
1
2
|
const { debug } = require('../debug')
|
|
2
3
|
const { exec } = require('child_process')
|
|
3
4
|
|
|
4
5
|
const d = debug('localGetFileAtSHA')
|
|
5
6
|
|
|
6
|
-
|
|
7
|
-
|
|
7
|
+
/**
|
|
8
|
+
* Gets file contents at a specific git commit (internal)
|
|
9
|
+
* @param {string} path - File path relative to git root
|
|
10
|
+
* @param {string|null} _repo - Unused, kept for interface compatibility
|
|
11
|
+
* @param {string} sha - Git commit SHA or ref
|
|
12
|
+
* @param {Object} [options] - Options
|
|
13
|
+
* @param {string} [options.cwd] - Working directory (defaults to process.cwd())
|
|
14
|
+
* @returns {Promise<string|undefined>} File contents or undefined if not found
|
|
15
|
+
*/
|
|
16
|
+
const localGetFileAtSHA = (path, _repo, sha, options = {}) => {
|
|
17
|
+
const cwd = options.cwd || process.cwd()
|
|
18
|
+
/** @type {Promise<string|undefined>} */
|
|
19
|
+
const promise = new Promise(resolve => {
|
|
8
20
|
const call = `git show ${sha}:'${path}'`
|
|
9
21
|
d(call)
|
|
10
|
-
exec(call, (err, stdout, _stderr) => {
|
|
22
|
+
exec(call, { cwd }, (err, stdout, _stderr) => {
|
|
11
23
|
if (err) {
|
|
12
|
-
|
|
13
|
-
// console.error(err)
|
|
14
|
-
return resolve()
|
|
24
|
+
return resolve(undefined)
|
|
15
25
|
}
|
|
16
26
|
resolve(stdout)
|
|
17
27
|
})
|
|
18
28
|
})
|
|
29
|
+
return promise
|
|
19
30
|
}
|
|
20
31
|
|
|
21
32
|
module.exports.localGetFileAtSHA = localGetFileAtSHA
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
// https://github.com/kpdecker/jsdiff/blob/master/test/patch/create.js
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
// https://github.com/humanwhocodes/gitignore-to-minimatch
|
|
@@ -0,0 +1,136 @@
|
|
|
1
|
+
const process = require('process')
|
|
2
|
+
const { execFileSync } = require('child_process')
|
|
3
|
+
const fs = require('fs')
|
|
4
|
+
const tty = require('tty')
|
|
5
|
+
|
|
6
|
+
const defaultColumns = 80
|
|
7
|
+
const defaultRows = 24
|
|
8
|
+
|
|
9
|
+
/**
|
|
10
|
+
* @typedef {Object} TerminalSize
|
|
11
|
+
* @property {number} columns - Number of columns in the terminal
|
|
12
|
+
* @property {number} rows - Number of rows in the terminal
|
|
13
|
+
*/
|
|
14
|
+
|
|
15
|
+
/** @type {TerminalSize|undefined} */
|
|
16
|
+
let sizeCache
|
|
17
|
+
|
|
18
|
+
// @ts-ignore
|
|
19
|
+
function exec(command, arguments_, { shell, env } = {}) {
|
|
20
|
+
return execFileSync(command, arguments_, {
|
|
21
|
+
encoding: 'utf8',
|
|
22
|
+
stdio: ['ignore', 'pipe', 'ignore'],
|
|
23
|
+
timeout: 500,
|
|
24
|
+
shell,
|
|
25
|
+
env,
|
|
26
|
+
}).trim()
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
function create(columns, rows) {
|
|
30
|
+
return {
|
|
31
|
+
columns: Number.parseInt(columns, 10),
|
|
32
|
+
rows: Number.parseInt(rows, 10),
|
|
33
|
+
}
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
function createIfNotDefault(maybeColumns, maybeRows) {
|
|
37
|
+
const { columns, rows } = create(maybeColumns, maybeRows)
|
|
38
|
+
|
|
39
|
+
if (Number.isNaN(columns) || Number.isNaN(rows)) {
|
|
40
|
+
return
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
if (columns === defaultColumns && rows === defaultRows) {
|
|
44
|
+
return
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
return { columns, rows }
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
function devTty() {
|
|
51
|
+
try {
|
|
52
|
+
// eslint-disable-next-line no-bitwise
|
|
53
|
+
// @ts-ignore - O_EVTONLY is macOS-specific
|
|
54
|
+
const flags = process.platform === 'darwin' ? fs.constants.O_EVTONLY | fs.constants.O_NONBLOCK : fs.constants.O_NONBLOCK
|
|
55
|
+
// eslint-disable-next-line new-cap
|
|
56
|
+
// @ts-ignore
|
|
57
|
+
const { columns, rows } = tty.WriteStream(fs.openSync('/dev/tty', flags))
|
|
58
|
+
return { columns, rows }
|
|
59
|
+
} catch {}
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
// On macOS, this only returns correct values when stdout is not redirected.
|
|
63
|
+
function tput() {
|
|
64
|
+
try {
|
|
65
|
+
// `tput` requires the `TERM` environment variable to be set.
|
|
66
|
+
const columns = exec('tput', ['cols'], { env: { TERM: 'dumb', ...process.env } })
|
|
67
|
+
const rows = exec('tput', ['lines'], { env: { TERM: 'dumb', ...process.env } })
|
|
68
|
+
|
|
69
|
+
if (columns && rows) {
|
|
70
|
+
return createIfNotDefault(columns, rows)
|
|
71
|
+
}
|
|
72
|
+
} catch {}
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
// Only exists on Linux.
|
|
76
|
+
function resize() {
|
|
77
|
+
// `resize` is preferred as it works even when all file descriptors are redirected
|
|
78
|
+
// https://linux.die.net/man/1/resize
|
|
79
|
+
try {
|
|
80
|
+
const size = exec('resize', ['-u']).match(/\d+/g)
|
|
81
|
+
|
|
82
|
+
if (size.length === 2) {
|
|
83
|
+
return createIfNotDefault(size[0], size[1])
|
|
84
|
+
}
|
|
85
|
+
} catch {}
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
function terminalSize() {
|
|
89
|
+
// Return cached size if available for performance.
|
|
90
|
+
if (sizeCache) {
|
|
91
|
+
return sizeCache
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
const { env, stdout, stderr } = process
|
|
95
|
+
|
|
96
|
+
if (stdout?.columns && stdout?.rows) {
|
|
97
|
+
sizeCache = create(stdout.columns, stdout.rows)
|
|
98
|
+
return sizeCache
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
if (stderr?.columns && stderr?.rows) {
|
|
102
|
+
sizeCache = create(stderr.columns, stderr.rows)
|
|
103
|
+
return sizeCache
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
// These values are static, so not the first choice.
|
|
107
|
+
if (env.COLUMNS && env.LINES) {
|
|
108
|
+
sizeCache = create(env.COLUMNS, env.LINES)
|
|
109
|
+
return sizeCache
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
const fallback = {
|
|
113
|
+
columns: defaultColumns,
|
|
114
|
+
rows: defaultRows,
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
if (process.platform === 'win32') {
|
|
118
|
+
// We include `tput` for Windows users using Git Bash.
|
|
119
|
+
sizeCache = tput() ?? fallback
|
|
120
|
+
return sizeCache
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
if (process.platform === 'darwin') {
|
|
124
|
+
sizeCache = devTty() ?? tput() ?? fallback
|
|
125
|
+
return sizeCache
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
sizeCache = devTty() ?? tput() ?? resize() ?? fallback
|
|
129
|
+
return sizeCache
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
if (require.main === module) {
|
|
133
|
+
console.log(terminalSize())
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
module.exports = terminalSize
|
|
@@ -0,0 +1,44 @@
|
|
|
1
|
+
const path = require('path')
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* Validates and sanitizes a file path to prevent command injection
|
|
5
|
+
* @param {string} filePath - The file path to validate
|
|
6
|
+
* @returns {string} The validated file path
|
|
7
|
+
* @throws {Error} If the file path contains suspicious characters
|
|
8
|
+
*/
|
|
9
|
+
function validateFilePath(filePath) {
|
|
10
|
+
if (!filePath || typeof filePath !== 'string') {
|
|
11
|
+
throw new Error('File path must be a non-empty string')
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
// Check for null bytes (common injection technique)
|
|
15
|
+
if (filePath.includes('\0')) {
|
|
16
|
+
throw new Error('File path contains null bytes')
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
// Check for command injection attempts
|
|
20
|
+
const dangerousPatterns = [
|
|
21
|
+
/[;&|`$(){}[\]<>]/, // Shell metacharacters
|
|
22
|
+
/\n|\r/, // Newlines
|
|
23
|
+
/\.\.\//, // Path traversal attempts (relative)
|
|
24
|
+
/^\.\.\\/, // Path traversal attempts (Windows)
|
|
25
|
+
]
|
|
26
|
+
|
|
27
|
+
for (const pattern of dangerousPatterns) {
|
|
28
|
+
if (pattern.test(filePath)) {
|
|
29
|
+
throw new Error(`File path contains suspicious characters: ${filePath}`)
|
|
30
|
+
}
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
// Normalize the path to prevent traversal attacks
|
|
34
|
+
const normalized = path.normalize(filePath)
|
|
35
|
+
|
|
36
|
+
// Additional check after normalization for path traversal
|
|
37
|
+
if (normalized.includes('..')) {
|
|
38
|
+
throw new Error(`File path contains directory traversal: ${filePath}`)
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
return filePath
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
module.exports = { validateFilePath }
|
package/src/index.js
CHANGED
|
@@ -4,12 +4,32 @@ const { getFirstCommit } = require('./git/commits/getFirstCommit')
|
|
|
4
4
|
const { getLastCommit } = require('./git/commits/getLastCommit')
|
|
5
5
|
const { getAllCommits } = require('./git/commits/getAllCommits')
|
|
6
6
|
const { getGitFiles } = require('./git/getGitFiles')
|
|
7
|
+
const { getGitRoot } = require('./git/getGitRoot')
|
|
8
|
+
const { getRemotes, getRemote } = require('./git/remotes/getRemotes')
|
|
9
|
+
const { getFileAtCommit } = require('./git/getFileAtCommit')
|
|
10
|
+
const {
|
|
11
|
+
getFileModifiedTimeStamp,
|
|
12
|
+
getFileCreatedTimeStamp,
|
|
13
|
+
getFileDates
|
|
14
|
+
} = require('./git/dates/getFileDates')
|
|
7
15
|
|
|
8
16
|
module.exports = {
|
|
17
|
+
// Get Git Details
|
|
18
|
+
gitDetails,
|
|
19
|
+
getGitRoot,
|
|
20
|
+
// Get Commits
|
|
9
21
|
getCommit,
|
|
22
|
+
getAllCommits,
|
|
10
23
|
getFirstCommit,
|
|
11
24
|
getLastCommit,
|
|
12
|
-
|
|
13
|
-
|
|
14
|
-
|
|
25
|
+
// Get Git Files
|
|
26
|
+
getGitFiles,
|
|
27
|
+
getFileAtCommit,
|
|
28
|
+
// Get Git File time details
|
|
29
|
+
getFileModifiedTimeStamp,
|
|
30
|
+
getFileCreatedTimeStamp,
|
|
31
|
+
getFileDates,
|
|
32
|
+
// Get Git Remotes
|
|
33
|
+
getRemotes,
|
|
34
|
+
getRemote,
|
|
15
35
|
}
|
package/src/types.js
CHANGED
|
@@ -47,4 +47,12 @@
|
|
|
47
47
|
* @property {function(): Promise<number>} linesOfCode - Async function that returns total lines of code changed
|
|
48
48
|
*/
|
|
49
49
|
|
|
50
|
+
/**
|
|
51
|
+
* @typedef {Object} FileDateInfo
|
|
52
|
+
* @property {number} created - Unix timestamp (seconds) of when the file was first committed
|
|
53
|
+
* @property {number} modified - Unix timestamp (seconds) of when the file was last modified
|
|
54
|
+
* @property {Date} createdDate - JavaScript Date object of creation time
|
|
55
|
+
* @property {Date} modifiedDate - JavaScript Date object of last modification
|
|
56
|
+
*/
|
|
57
|
+
|
|
50
58
|
module.exports = {}
|
|
@@ -0,0 +1,82 @@
|
|
|
1
|
+
export type FileDateInfo = {
|
|
2
|
+
/**
|
|
3
|
+
* - Unix timestamp (seconds) of when the file was first committed
|
|
4
|
+
*/
|
|
5
|
+
created: number;
|
|
6
|
+
/**
|
|
7
|
+
* - Unix timestamp (seconds) of when the file was last modified
|
|
8
|
+
*/
|
|
9
|
+
modified: number;
|
|
10
|
+
/**
|
|
11
|
+
* - JavaScript Date object of creation time
|
|
12
|
+
*/
|
|
13
|
+
createdDate: Date;
|
|
14
|
+
/**
|
|
15
|
+
* - JavaScript Date object of last modification
|
|
16
|
+
*/
|
|
17
|
+
modifiedDate: Date;
|
|
18
|
+
};
|
|
19
|
+
export type FileDateError = {
|
|
20
|
+
/**
|
|
21
|
+
* - Error message when file date retrieval fails
|
|
22
|
+
*/
|
|
23
|
+
error: string;
|
|
24
|
+
};
|
|
25
|
+
/**
|
|
26
|
+
* @typedef {Object} FileDateInfo
|
|
27
|
+
* @property {number} created - Unix timestamp (seconds) of when the file was first committed
|
|
28
|
+
* @property {number} modified - Unix timestamp (seconds) of when the file was last modified
|
|
29
|
+
* @property {Date} createdDate - JavaScript Date object of creation time
|
|
30
|
+
* @property {Date} modifiedDate - JavaScript Date object of last modification
|
|
31
|
+
*/
|
|
32
|
+
/**
|
|
33
|
+
* @typedef {Object} FileDateError
|
|
34
|
+
* @property {string} error - Error message when file date retrieval fails
|
|
35
|
+
*/
|
|
36
|
+
/**
|
|
37
|
+
* Gets the last modification date of a file from git history
|
|
38
|
+
* @param {string} filePath - Path to the file (absolute or relative to git root)
|
|
39
|
+
* @param {Object} [options] - Options
|
|
40
|
+
* @param {string} [options.cwd] - Current working directory (defaults to process.cwd())
|
|
41
|
+
* @returns {Promise<number>} Promise that resolves to Unix timestamp (seconds) of last modification
|
|
42
|
+
* @example
|
|
43
|
+
* const modifiedDate = await getFileModifiedTimeStamp('src/index.js')
|
|
44
|
+
* console.log('Last modified:', new Date(modifiedDate * 1000))
|
|
45
|
+
*/
|
|
46
|
+
export function getFileModifiedTimeStamp(filePath: string, options?: {
|
|
47
|
+
cwd?: string;
|
|
48
|
+
}): Promise<number>;
|
|
49
|
+
/**
|
|
50
|
+
* Gets the creation date of a file from git history (first commit)
|
|
51
|
+
* @param {string} filePath - Path to the file (absolute or relative to git root)
|
|
52
|
+
* @param {Object} [options] - Options
|
|
53
|
+
* @param {string} [options.cwd] - Current working directory (defaults to process.cwd())
|
|
54
|
+
* @returns {Promise<number>} Promise that resolves to Unix timestamp (seconds) of creation
|
|
55
|
+
* @example
|
|
56
|
+
* const createdDate = await getFileCreatedTimeStamp('src/index.js')
|
|
57
|
+
* console.log('Created:', new Date(createdDate * 1000))
|
|
58
|
+
*/
|
|
59
|
+
export function getFileCreatedTimeStamp(filePath: string, options?: {
|
|
60
|
+
cwd?: string;
|
|
61
|
+
}): Promise<number>;
|
|
62
|
+
/**
|
|
63
|
+
* Gets both creation and modification dates for file(s) from git history
|
|
64
|
+
* @param {string | string[]} filePaths - Path or array of paths to file(s)
|
|
65
|
+
* @param {Object} [options] - Options
|
|
66
|
+
* @param {string} [options.cwd] - Current working directory (defaults to process.cwd())
|
|
67
|
+
* @returns {Promise<FileDateInfo | Object<string, FileDateInfo | FileDateError>>} Single file returns FileDateInfo, multiple files returns object keyed by path
|
|
68
|
+
* @example
|
|
69
|
+
* // Single file
|
|
70
|
+
* const dates = await getFileDates('src/index.js')
|
|
71
|
+
* console.log('Created:', dates.createdDate)
|
|
72
|
+
* console.log('Modified:', dates.modifiedDate)
|
|
73
|
+
*
|
|
74
|
+
* // Multiple files
|
|
75
|
+
* const dates = await getFileDates(['src/index.js', 'README.md'])
|
|
76
|
+
* console.log('Index modified:', dates['src/index.js'].modifiedDate)
|
|
77
|
+
*/
|
|
78
|
+
export function getFileDates(filePaths: string | string[], options?: {
|
|
79
|
+
cwd?: string;
|
|
80
|
+
}): Promise<FileDateInfo | {
|
|
81
|
+
[x: string]: FileDateInfo | FileDateError;
|
|
82
|
+
}>;
|
|
@@ -0,0 +1,20 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Get formatted diff for a specific file
|
|
3
|
+
* @param {Object} options - Options for getting formatted diff
|
|
4
|
+
* @param {string} options.filePath - Relative path from git root
|
|
5
|
+
* @param {string} options.gitRootDir - Absolute path to git root directory
|
|
6
|
+
* @param {string} options.baseBranch - Base branch to compare against
|
|
7
|
+
* @param {boolean} options.shrinkToLongestLine - Auto-calculate width based on longest line
|
|
8
|
+
* @param {number} options.leftMargin - Number of spaces to add to the left of each line
|
|
9
|
+
* @param {number} options.width - Width of the diff output (ignored if shrinkToLongestLine is true)
|
|
10
|
+
* @param {boolean} options.hideHeader - Remove the file path header from the diff
|
|
11
|
+
*/
|
|
12
|
+
export function getFormattedDiff({ filePath, gitRootDir, baseBranch, shrinkToLongestLine, leftMargin, width, hideHeader }: {
|
|
13
|
+
filePath: string;
|
|
14
|
+
gitRootDir: string;
|
|
15
|
+
baseBranch: string;
|
|
16
|
+
shrinkToLongestLine: boolean;
|
|
17
|
+
leftMargin: number;
|
|
18
|
+
width: number;
|
|
19
|
+
hideHeader: boolean;
|
|
20
|
+
}): Promise<any>;
|
|
@@ -0,0 +1,17 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Gets the contents of a file at a specific git commit
|
|
3
|
+
* @param {string} filePath - Path to the file (relative to git root)
|
|
4
|
+
* @param {string} sha - Git commit SHA or ref (e.g., 'HEAD', 'main', 'abc123')
|
|
5
|
+
* @param {Object} [options] - Options
|
|
6
|
+
* @param {string} [options.cwd] - Working directory (defaults to process.cwd())
|
|
7
|
+
* @returns {Promise<string|undefined>} File contents at that commit, or undefined if not found
|
|
8
|
+
* @example
|
|
9
|
+
* const contents = await getFileAtCommit('src/index.js', 'HEAD~1')
|
|
10
|
+
* console.log('Previous version:', contents)
|
|
11
|
+
*
|
|
12
|
+
* // With cwd option
|
|
13
|
+
* const contents = await getFileAtCommit('package.json', 'HEAD', { cwd: '/path/to/repo' })
|
|
14
|
+
*/
|
|
15
|
+
export function getFileAtCommit(filePath: string, sha: string, options?: {
|
|
16
|
+
cwd?: string;
|
|
17
|
+
}): Promise<string | undefined>;
|
|
@@ -1 +1,12 @@
|
|
|
1
|
-
|
|
1
|
+
/**
|
|
2
|
+
* Gets file contents at a specific git commit (internal)
|
|
3
|
+
* @param {string} path - File path relative to git root
|
|
4
|
+
* @param {string|null} _repo - Unused, kept for interface compatibility
|
|
5
|
+
* @param {string} sha - Git commit SHA or ref
|
|
6
|
+
* @param {Object} [options] - Options
|
|
7
|
+
* @param {string} [options.cwd] - Working directory (defaults to process.cwd())
|
|
8
|
+
* @returns {Promise<string|undefined>} File contents or undefined if not found
|
|
9
|
+
*/
|
|
10
|
+
export function localGetFileAtSHA(path: string, _repo: string | null, sha: string, options?: {
|
|
11
|
+
cwd?: string;
|
|
12
|
+
}): Promise<string | undefined>;
|
|
File without changes
|
|
File without changes
|
|
@@ -0,0 +1,15 @@
|
|
|
1
|
+
export = terminalSize;
|
|
2
|
+
declare function terminalSize(): TerminalSize;
|
|
3
|
+
declare namespace terminalSize {
|
|
4
|
+
export { TerminalSize };
|
|
5
|
+
}
|
|
6
|
+
type TerminalSize = {
|
|
7
|
+
/**
|
|
8
|
+
* - Number of columns in the terminal
|
|
9
|
+
*/
|
|
10
|
+
columns: number;
|
|
11
|
+
/**
|
|
12
|
+
* - Number of rows in the terminal
|
|
13
|
+
*/
|
|
14
|
+
rows: number;
|
|
15
|
+
};
|
|
@@ -0,0 +1,7 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Validates and sanitizes a file path to prevent command injection
|
|
3
|
+
* @param {string} filePath - The file path to validate
|
|
4
|
+
* @returns {string} The validated file path
|
|
5
|
+
* @throws {Error} If the file path contains suspicious characters
|
|
6
|
+
*/
|
|
7
|
+
export function validateFilePath(filePath: string): string;
|
package/types/index.d.ts
CHANGED
|
@@ -1,7 +1,14 @@
|
|
|
1
|
+
import { gitDetails } from "./git/getDetails";
|
|
2
|
+
import { getGitRoot } from "./git/getGitRoot";
|
|
1
3
|
import { getCommit } from "./git/commits/getCommit";
|
|
4
|
+
import { getAllCommits } from "./git/commits/getAllCommits";
|
|
2
5
|
import { getFirstCommit } from "./git/commits/getFirstCommit";
|
|
3
6
|
import { getLastCommit } from "./git/commits/getLastCommit";
|
|
4
|
-
import { getAllCommits } from "./git/commits/getAllCommits";
|
|
5
|
-
import { gitDetails } from "./git/getDetails";
|
|
6
7
|
import { getGitFiles } from "./git/getGitFiles";
|
|
7
|
-
|
|
8
|
+
import { getFileAtCommit } from "./git/getFileAtCommit";
|
|
9
|
+
import { getFileModifiedTimeStamp } from "./git/dates/getFileDates";
|
|
10
|
+
import { getFileCreatedTimeStamp } from "./git/dates/getFileDates";
|
|
11
|
+
import { getFileDates } from "./git/dates/getFileDates";
|
|
12
|
+
import { getRemotes } from "./git/remotes/getRemotes";
|
|
13
|
+
import { getRemote } from "./git/remotes/getRemotes";
|
|
14
|
+
export { gitDetails, getGitRoot, getCommit, getAllCommits, getFirstCommit, getLastCommit, getGitFiles, getFileAtCommit, getFileModifiedTimeStamp, getFileCreatedTimeStamp, getFileDates, getRemotes, getRemote };
|
package/types/types.d.ts
CHANGED
|
@@ -126,3 +126,21 @@ export type GitDetails = {
|
|
|
126
126
|
*/
|
|
127
127
|
linesOfCode: () => Promise<number>;
|
|
128
128
|
};
|
|
129
|
+
export type FileDateInfo = {
|
|
130
|
+
/**
|
|
131
|
+
* - Unix timestamp (seconds) of when the file was first committed
|
|
132
|
+
*/
|
|
133
|
+
created: number;
|
|
134
|
+
/**
|
|
135
|
+
* - Unix timestamp (seconds) of when the file was last modified
|
|
136
|
+
*/
|
|
137
|
+
modified: number;
|
|
138
|
+
/**
|
|
139
|
+
* - JavaScript Date object of creation time
|
|
140
|
+
*/
|
|
141
|
+
createdDate: Date;
|
|
142
|
+
/**
|
|
143
|
+
* - JavaScript Date object of last modification
|
|
144
|
+
*/
|
|
145
|
+
modifiedDate: Date;
|
|
146
|
+
};
|