git-truck 0.5.0
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/.eslintrc.json +20 -0
- package/.github/workflows/test-and-build.yml +39 -0
- package/.husky/pre-commit +4 -0
- package/.truckignore +1 -0
- package/.vscode/extensions.json +7 -0
- package/.vscode/launch.json +24 -0
- package/.vscode/settings.json +6 -0
- package/LICENSE +21 -0
- package/README.md +60 -0
- package/app/README.md +46 -0
- package/app/entry.client.tsx +4 -0
- package/app/entry.server.tsx +27 -0
- package/app/parser/.eslintignore +3 -0
- package/app/parser/.eslintrc.json +18 -0
- package/app/parser/src/TruckIgnore.server.ts +20 -0
- package/app/parser/src/constants.ts +1 -0
- package/app/parser/src/hydrate.server.ts +199 -0
- package/app/parser/src/index.ts +5 -0
- package/app/parser/src/log.server.ts +97 -0
- package/app/parser/src/model.ts +77 -0
- package/app/parser/src/parse.server.ts +276 -0
- package/app/parser/src/parse.test.ts +32 -0
- package/app/parser/src/queue.ts +86 -0
- package/app/parser/src/util.test.ts +8 -0
- package/app/parser/src/util.ts +216 -0
- package/app/root.tsx +35 -0
- package/app/routes/index.tsx +43 -0
- package/app/src/authorUnionUtil.test.ts +82 -0
- package/app/src/authorUnionUtil.ts +52 -0
- package/app/src/components/AuthorDistFragment.tsx +27 -0
- package/app/src/components/AuthorDistOther.tsx +24 -0
- package/app/src/components/Chart.tsx +362 -0
- package/app/src/components/Details.tsx +177 -0
- package/app/src/components/EnumSelect.tsx +31 -0
- package/app/src/components/GlobalInfo.tsx +17 -0
- package/app/src/components/Legend.tsx +65 -0
- package/app/src/components/LegendFragment.tsx +29 -0
- package/app/src/components/LegendOther.tsx +43 -0
- package/app/src/components/Main.tsx +19 -0
- package/app/src/components/Options.tsx +24 -0
- package/app/src/components/Providers.tsx +121 -0
- package/app/src/components/SearchBar.tsx +36 -0
- package/app/src/components/SidePanel.tsx +25 -0
- package/app/src/components/Spacer.tsx +62 -0
- package/app/src/components/Toggle.tsx +21 -0
- package/app/src/components/Tooltip.tsx +131 -0
- package/app/src/components/util.tsx +150 -0
- package/app/src/const.ts +5 -0
- package/app/src/contexts/DataContext.ts +12 -0
- package/app/src/contexts/MetricContext.ts +14 -0
- package/app/src/contexts/OptionsContext.ts +46 -0
- package/app/src/contexts/SearchContext.ts +16 -0
- package/app/src/extension-color.ts +34 -0
- package/app/src/hooks.ts +17 -0
- package/app/src/lang-map.d.ts +3 -0
- package/app/src/metrics.ts +319 -0
- package/app/src/react-app-env.d.ts +1 -0
- package/app/src/reportWebVitals.ts +15 -0
- package/app/src/setupTests.ts +5 -0
- package/app/src/util.ts +33 -0
- package/app/styles/App.css +3 -0
- package/app/styles/Chart.css +26 -0
- package/app/styles/index.css +35 -0
- package/app/styles/vars.css +17 -0
- package/cli.js +2 -0
- package/package.json +99 -0
- package/parse.sh +26 -0
- package/project-statement.md +43 -0
- package/public/favicon.ico +0 -0
- package/remix.config.js +21 -0
- package/remix.env.d.ts +2 -0
- package/server.js +41 -0
- package/truckconfig.json +8 -0
- package/tsconfig.json +20 -0
|
@@ -0,0 +1,77 @@
|
|
|
1
|
+
export type GitObject = GitBlobObject | GitTreeObject
|
|
2
|
+
|
|
3
|
+
export interface GitBaseObject {
|
|
4
|
+
type: "blob" | "tree" | "commit"
|
|
5
|
+
hash: string
|
|
6
|
+
}
|
|
7
|
+
|
|
8
|
+
export interface ParserData {
|
|
9
|
+
repo: string
|
|
10
|
+
branch: string
|
|
11
|
+
commit: HydratedGitCommitObject
|
|
12
|
+
authorUnions: string[][]
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
export interface GitBlobObject extends GitBaseObject {
|
|
16
|
+
type: "blob"
|
|
17
|
+
name: string
|
|
18
|
+
path: string
|
|
19
|
+
content?: string
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
export type HydratedGitObject = HydratedGitBlobObject | HydratedGitTreeObject
|
|
23
|
+
|
|
24
|
+
export interface HydratedGitBlobObject extends GitBlobObject {
|
|
25
|
+
noLines: number
|
|
26
|
+
authors: Record<string, number>
|
|
27
|
+
unionedAuthors?: Record<string, number>
|
|
28
|
+
noCommits: number
|
|
29
|
+
lastChangeEpoch?: number
|
|
30
|
+
dominantAuthor?: [string, number]
|
|
31
|
+
isBinary?: boolean
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
export interface GitTreeObject extends GitBaseObject {
|
|
35
|
+
type: "tree"
|
|
36
|
+
name: string
|
|
37
|
+
path: string
|
|
38
|
+
children: (GitTreeObject | GitBlobObject)[]
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
export interface HydratedGitTreeObject extends Omit<GitTreeObject, "children"> {
|
|
42
|
+
children: (HydratedGitTreeObject | HydratedGitBlobObject)[]
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
export interface GitCommitObject extends GitBaseObject {
|
|
46
|
+
type: "commit"
|
|
47
|
+
tree: GitTreeObject
|
|
48
|
+
parent: string
|
|
49
|
+
parent2: string | null
|
|
50
|
+
author: PersonWithTime
|
|
51
|
+
committer: PersonWithTime
|
|
52
|
+
message: string
|
|
53
|
+
description: string
|
|
54
|
+
coauthors: Person[]
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
export interface HydratedGitCommitObject extends Omit<GitCommitObject, "tree"> {
|
|
58
|
+
tree: HydratedGitTreeObject
|
|
59
|
+
minNoCommits: number
|
|
60
|
+
maxNoCommits: number
|
|
61
|
+
newestLatestChangeEpoch: number
|
|
62
|
+
oldestLatestChangeEpoch: number
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
export type GitCommitObjectLight = Omit<GitCommitObject, "tree"> & {
|
|
66
|
+
tree: string
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
export interface Person {
|
|
70
|
+
name: string
|
|
71
|
+
email: string
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
export type PersonWithTime = Person & {
|
|
75
|
+
timestamp: number
|
|
76
|
+
timezone: string
|
|
77
|
+
}
|
|
@@ -0,0 +1,276 @@
|
|
|
1
|
+
import fsSync, { promises as fs, readFileSync } from "fs"
|
|
2
|
+
import {
|
|
3
|
+
GitBlobObject,
|
|
4
|
+
GitCommitObject,
|
|
5
|
+
GitCommitObjectLight,
|
|
6
|
+
GitTreeObject,
|
|
7
|
+
Person,
|
|
8
|
+
} from "./model"
|
|
9
|
+
import { log, setLogLevel } from "./log.server"
|
|
10
|
+
import {
|
|
11
|
+
describeAsyncJob,
|
|
12
|
+
formatMs,
|
|
13
|
+
writeRepoToFile,
|
|
14
|
+
getCurrentBranch,
|
|
15
|
+
getRepoName,
|
|
16
|
+
deflateGitObject,
|
|
17
|
+
} from "./util"
|
|
18
|
+
import { emptyGitCommitHash } from "./constants"
|
|
19
|
+
import { resolve , isAbsolute, join} from "path"
|
|
20
|
+
import TruckIgnore from "./TruckIgnore.server"
|
|
21
|
+
import { performance } from "perf_hooks"
|
|
22
|
+
import yargsParser from "yargs-parser"
|
|
23
|
+
import { hydrateData } from "./hydrate.server"
|
|
24
|
+
|
|
25
|
+
import { } from "@remix-run/node"
|
|
26
|
+
|
|
27
|
+
export async function findBranchHead(repo: string, branch: string | null) {
|
|
28
|
+
if (branch === null) branch = await getCurrentBranch(repo)
|
|
29
|
+
|
|
30
|
+
const gitFolder = join(repo, ".git")
|
|
31
|
+
if (!fsSync.existsSync(gitFolder)) {
|
|
32
|
+
throw Error(`${repo} is not a git repository`)
|
|
33
|
+
}
|
|
34
|
+
// Find file containing the branch head
|
|
35
|
+
const branchPath = join(gitFolder, "refs/heads/" + branch)
|
|
36
|
+
const absolutePath = join(process.cwd(), branchPath)
|
|
37
|
+
log.debug("Looking for branch head at " + absolutePath)
|
|
38
|
+
|
|
39
|
+
const branchHead = (await fs.readFile(branchPath, "utf-8")).trim()
|
|
40
|
+
log.debug(`${branch} -> [commit]${branchHead}`)
|
|
41
|
+
|
|
42
|
+
return [branchHead, branch]
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
export async function parseCommitLight(
|
|
46
|
+
repo: string,
|
|
47
|
+
hash: string
|
|
48
|
+
): Promise<GitCommitObjectLight> {
|
|
49
|
+
const rawContent = await deflateGitObject(repo, hash)
|
|
50
|
+
const commitRegex =
|
|
51
|
+
/tree (?<tree>.*)\n(?:parent (?<parent>.*)\n)?(?:parent (?<parent2>.*)\n)?author (?<authorName>.*) <(?<authorEmail>.*)> (?<authorTimeStamp>\d*) (?<authorTimeZone>.*)\ncommitter (?<committerName>.*) <(?<committerEmail>.*)> (?<committerTimeStamp>\d*) (?<committerTimeZone>.*)\n(?:gpgsig (?:.|\n)*-----END PGP SIGNATURE-----)?\s*(?<message>.*)\s*(?<description>(?:.|\s)*)/gm
|
|
52
|
+
|
|
53
|
+
const match = commitRegex.exec(rawContent)
|
|
54
|
+
const groups = match?.groups ?? {}
|
|
55
|
+
|
|
56
|
+
const tree = groups["tree"]
|
|
57
|
+
const parent = groups["parent"] ?? emptyGitCommitHash
|
|
58
|
+
const parent2 = groups["parent2"] ?? null
|
|
59
|
+
const author = {
|
|
60
|
+
name: groups["authorName"],
|
|
61
|
+
email: groups["authorEmail"],
|
|
62
|
+
timestamp: Number(groups["authorTimeStamp"]),
|
|
63
|
+
timezone: groups["authorTimeZone"],
|
|
64
|
+
}
|
|
65
|
+
const committer = {
|
|
66
|
+
name: groups["committerName"],
|
|
67
|
+
email: groups["committerEmail"],
|
|
68
|
+
timestamp: Number(groups["committerTimeStamp"]),
|
|
69
|
+
timezone: groups["committerTimeZone"],
|
|
70
|
+
}
|
|
71
|
+
const message = groups["message"]
|
|
72
|
+
const description = groups["description"]
|
|
73
|
+
const coauthors = description ? getCoAuthors(description) : []
|
|
74
|
+
|
|
75
|
+
return {
|
|
76
|
+
type: "commit",
|
|
77
|
+
hash,
|
|
78
|
+
tree,
|
|
79
|
+
parent,
|
|
80
|
+
parent2,
|
|
81
|
+
author,
|
|
82
|
+
committer,
|
|
83
|
+
message,
|
|
84
|
+
description,
|
|
85
|
+
coauthors,
|
|
86
|
+
}
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
export async function parseCommit(
|
|
90
|
+
repo: string,
|
|
91
|
+
repoName: string,
|
|
92
|
+
hash: string
|
|
93
|
+
): Promise<GitCommitObject> {
|
|
94
|
+
const { tree, ...commit } = await parseCommitLight(repo, hash)
|
|
95
|
+
const truckignore = new TruckIgnore(repo)
|
|
96
|
+
return {
|
|
97
|
+
...commit,
|
|
98
|
+
tree: await parseTree(getRepoName(repo), repo, repoName, tree, truckignore),
|
|
99
|
+
}
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
function getCoAuthors(description: string) {
|
|
103
|
+
const coauthorRegex = /.*Co-authored-by: (?<name>.*) <(?<email>.*)>/gm
|
|
104
|
+
const coauthormatches = description.matchAll(coauthorRegex)
|
|
105
|
+
let next = coauthormatches.next()
|
|
106
|
+
const coauthors: Person[] = []
|
|
107
|
+
|
|
108
|
+
while (next.value !== undefined) {
|
|
109
|
+
coauthors.push({
|
|
110
|
+
name: next.value.groups["name"].trimEnd(),
|
|
111
|
+
email: next.value.groups["email"],
|
|
112
|
+
})
|
|
113
|
+
next = coauthormatches.next()
|
|
114
|
+
}
|
|
115
|
+
return coauthors
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
async function parseTree(
|
|
119
|
+
path: string,
|
|
120
|
+
repo: string,
|
|
121
|
+
name: string,
|
|
122
|
+
hash: string,
|
|
123
|
+
truckignore: TruckIgnore
|
|
124
|
+
): Promise<GitTreeObject> {
|
|
125
|
+
const rawContent = await deflateGitObject(repo, hash)
|
|
126
|
+
const entries = rawContent.split("\n").filter((x) => x.trim().length > 0)
|
|
127
|
+
|
|
128
|
+
const children: (GitTreeObject | GitBlobObject)[] = []
|
|
129
|
+
for await (const line of entries) {
|
|
130
|
+
const catFileRegex = /^.+?\s(?<type>\w+)\s(?<hash>.+?)\s+(?<name>.+?)\s*$/g;
|
|
131
|
+
const groups = catFileRegex.exec(line)?.groups ?? {}
|
|
132
|
+
|
|
133
|
+
const type = groups["type"]
|
|
134
|
+
const hash = groups["hash"]
|
|
135
|
+
const name = groups["name"]
|
|
136
|
+
|
|
137
|
+
if (!truckignore.isAccepted(name)) continue
|
|
138
|
+
const newPath = [path, name].join("/")
|
|
139
|
+
log.debug(`Path: ${newPath}`)
|
|
140
|
+
|
|
141
|
+
switch (type) {
|
|
142
|
+
case "tree":
|
|
143
|
+
children.push(await parseTree(newPath, repo, name, hash, truckignore))
|
|
144
|
+
break
|
|
145
|
+
case "blob":
|
|
146
|
+
children.push(await parseBlob(newPath, repo, name, hash))
|
|
147
|
+
break
|
|
148
|
+
default:
|
|
149
|
+
throw new Error(` type ${type}`)
|
|
150
|
+
}
|
|
151
|
+
}
|
|
152
|
+
|
|
153
|
+
return {
|
|
154
|
+
type: "tree",
|
|
155
|
+
path: path,
|
|
156
|
+
name,
|
|
157
|
+
hash,
|
|
158
|
+
children,
|
|
159
|
+
}
|
|
160
|
+
}
|
|
161
|
+
|
|
162
|
+
async function parseBlob(
|
|
163
|
+
path: string,
|
|
164
|
+
repo: string,
|
|
165
|
+
name: string,
|
|
166
|
+
hash: string
|
|
167
|
+
): Promise<GitBlobObject> {
|
|
168
|
+
const content = await deflateGitObject(repo, hash)
|
|
169
|
+
const blob: GitBlobObject = {
|
|
170
|
+
type: "blob",
|
|
171
|
+
hash,
|
|
172
|
+
path,
|
|
173
|
+
name,
|
|
174
|
+
content,
|
|
175
|
+
}
|
|
176
|
+
return blob
|
|
177
|
+
}
|
|
178
|
+
|
|
179
|
+
interface truckConfigResponse {
|
|
180
|
+
unionedAuthors: string[][]
|
|
181
|
+
}
|
|
182
|
+
|
|
183
|
+
export async function loadTruckConfig(repoDir: string) {
|
|
184
|
+
try {
|
|
185
|
+
const truckConfig = JSON.parse(
|
|
186
|
+
readFileSync(join(repoDir, "truckconfig.json"), "utf-8")
|
|
187
|
+
) as truckConfigResponse
|
|
188
|
+
return truckConfig.unionedAuthors
|
|
189
|
+
} catch (e) {
|
|
190
|
+
log.info("No truckignore found: " + e)
|
|
191
|
+
}
|
|
192
|
+
return []
|
|
193
|
+
}
|
|
194
|
+
|
|
195
|
+
export async function parse(rawArgs: string[]) {
|
|
196
|
+
const args = yargsParser(rawArgs, {
|
|
197
|
+
configuration: {
|
|
198
|
+
"duplicate-arguments-array": false,
|
|
199
|
+
},
|
|
200
|
+
})
|
|
201
|
+
|
|
202
|
+
if (args.log) {
|
|
203
|
+
setLogLevel(args.log)
|
|
204
|
+
}
|
|
205
|
+
|
|
206
|
+
if (args.help || args.h) {
|
|
207
|
+
console.log(`Git Visual
|
|
208
|
+
|
|
209
|
+
Usage: ./start.sh <args> or ./dev.sh <args>
|
|
210
|
+
|
|
211
|
+
Options:
|
|
212
|
+
--path <path to git repository> (default: current directory)
|
|
213
|
+
--branch <branch name> (default: checked out branch)
|
|
214
|
+
--out <output path for json file> (default: ./app/build/data.json)
|
|
215
|
+
--help, -h: Show this help message`)
|
|
216
|
+
process.exit(1)
|
|
217
|
+
}
|
|
218
|
+
|
|
219
|
+
const cwd = process.cwd()
|
|
220
|
+
|
|
221
|
+
let repoDir = args.path ?? "."
|
|
222
|
+
if (!isAbsolute(repoDir))
|
|
223
|
+
repoDir = resolve(cwd, repoDir)
|
|
224
|
+
|
|
225
|
+
const branch = args.branch ?? null
|
|
226
|
+
|
|
227
|
+
const start = performance.now()
|
|
228
|
+
const [branchHead, branchName] = await describeAsyncJob(
|
|
229
|
+
() => findBranchHead(repoDir, branch),
|
|
230
|
+
"Finding branch head",
|
|
231
|
+
"Found branch head",
|
|
232
|
+
"Error finding branch head"
|
|
233
|
+
)
|
|
234
|
+
const repoName = getRepoName(repoDir)
|
|
235
|
+
const repoTree = await describeAsyncJob(
|
|
236
|
+
() => parseCommit(repoDir, repoName, branchHead),
|
|
237
|
+
"Parsing commit tree",
|
|
238
|
+
"Commit tree parsed",
|
|
239
|
+
"Error parsing commit tree"
|
|
240
|
+
)
|
|
241
|
+
const hydratedRepoTree = await describeAsyncJob(
|
|
242
|
+
() => hydrateData(repoDir, repoTree),
|
|
243
|
+
"Hydrating commit tree with authorship data",
|
|
244
|
+
"Commit tree hydrated",
|
|
245
|
+
"Error hydrating commit tree"
|
|
246
|
+
)
|
|
247
|
+
|
|
248
|
+
const defaultOutPath = resolve(__dirname, `../.temp/${repoName}_${branchName}.json`)
|
|
249
|
+
let outPath = resolve(args.out ?? defaultOutPath)
|
|
250
|
+
if (!isAbsolute(outPath))
|
|
251
|
+
outPath = resolve(cwd, outPath)
|
|
252
|
+
|
|
253
|
+
const authorUnions = await loadTruckConfig(repoDir)
|
|
254
|
+
const data = {
|
|
255
|
+
repo: repoName,
|
|
256
|
+
branch: branchName,
|
|
257
|
+
commit: hydratedRepoTree,
|
|
258
|
+
authorUnions: authorUnions,
|
|
259
|
+
}
|
|
260
|
+
await describeAsyncJob(
|
|
261
|
+
() =>
|
|
262
|
+
writeRepoToFile(outPath, data),
|
|
263
|
+
"Writing data to file",
|
|
264
|
+
`Wrote data to ${resolve(outPath)}`,
|
|
265
|
+
`Error writing data to file ${outPath}`
|
|
266
|
+
)
|
|
267
|
+
const stop = performance.now()
|
|
268
|
+
|
|
269
|
+
log.raw(`\nDone in ${formatMs(stop - start)}`)
|
|
270
|
+
|
|
271
|
+
return data
|
|
272
|
+
}
|
|
273
|
+
|
|
274
|
+
export const exportForTest = {
|
|
275
|
+
getCoAuthors,
|
|
276
|
+
}
|
|
@@ -0,0 +1,32 @@
|
|
|
1
|
+
import { exportForTest } from "./parse.server"
|
|
2
|
+
jest.mock("./TruckIgnore.server")
|
|
3
|
+
jest.setTimeout(15000)
|
|
4
|
+
|
|
5
|
+
describe("getCoAuthors", () => {
|
|
6
|
+
it("Should return none", () => {
|
|
7
|
+
const actual = exportForTest.getCoAuthors("lorem ipsum\n\nCo-authored-by:")
|
|
8
|
+
expect(actual.length).toBe(0)
|
|
9
|
+
})
|
|
10
|
+
|
|
11
|
+
it("Should return none when empty input", () => {
|
|
12
|
+
const actual = exportForTest.getCoAuthors("")
|
|
13
|
+
expect(actual.length).toBe(0)
|
|
14
|
+
})
|
|
15
|
+
|
|
16
|
+
it("Should return 2 authors", () => {
|
|
17
|
+
const sampleDescription =
|
|
18
|
+
"did some stuff\n\nCo-authored-by: Bob Bobby <bob@example.com>\nCo-authored-by: Alice Lmao <alice@example.com>"
|
|
19
|
+
const expected = [
|
|
20
|
+
{
|
|
21
|
+
name: "Bob Bobby",
|
|
22
|
+
email: "bob@example.com",
|
|
23
|
+
},
|
|
24
|
+
{
|
|
25
|
+
name: "Alice Lmao",
|
|
26
|
+
email: "alice@example.com",
|
|
27
|
+
},
|
|
28
|
+
]
|
|
29
|
+
const actual = exportForTest.getCoAuthors(sampleDescription)
|
|
30
|
+
expect(actual).toStrictEqual(expected)
|
|
31
|
+
})
|
|
32
|
+
})
|
|
@@ -0,0 +1,86 @@
|
|
|
1
|
+
// Adapted from algs4 Implementation of Queue.java
|
|
2
|
+
// https://algs4.cs.princeton.edu/code/edu/princeton/cs/algs4/Queue.java.html
|
|
3
|
+
// algs4, Robert Sedgewick and Kevin Wayne
|
|
4
|
+
|
|
5
|
+
// helper linked list class
|
|
6
|
+
class QueueNode<T> {
|
|
7
|
+
item: T
|
|
8
|
+
next: QueueNode<T>|null = null
|
|
9
|
+
|
|
10
|
+
constructor(item : T) {
|
|
11
|
+
this.item = item
|
|
12
|
+
}
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
export class Queue<T> {
|
|
16
|
+
first : QueueNode<T>|null // beginning of queue
|
|
17
|
+
last : QueueNode<T>|null // end of queue
|
|
18
|
+
n : number // number of elements on queue
|
|
19
|
+
|
|
20
|
+
/**
|
|
21
|
+
* Initializes an empty queue.
|
|
22
|
+
*/
|
|
23
|
+
constructor() {
|
|
24
|
+
this.first = null
|
|
25
|
+
this.last = null
|
|
26
|
+
this.n = 0
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
/**
|
|
30
|
+
* Returns true if this queue is empty.
|
|
31
|
+
*
|
|
32
|
+
* @return {@code true} if this queue is empty {@code false} otherwise
|
|
33
|
+
*/
|
|
34
|
+
isEmpty() : boolean {
|
|
35
|
+
return this.first == null
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
/**
|
|
39
|
+
* Returns the number of items in this queue.
|
|
40
|
+
*
|
|
41
|
+
* @return the number of items in this queue
|
|
42
|
+
*/
|
|
43
|
+
size() : number {
|
|
44
|
+
return this.n
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
/**
|
|
48
|
+
* Returns the item least recently added to this queue.
|
|
49
|
+
*
|
|
50
|
+
* @return the item least recently added to this queue
|
|
51
|
+
* @throws NoSuchElementException if this queue is empty
|
|
52
|
+
*/
|
|
53
|
+
peek() : T|null {
|
|
54
|
+
if (this.isEmpty()) throw new Error("Queue underflow")
|
|
55
|
+
return this.first!.item // this.first should not be null
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
/**
|
|
59
|
+
* Adds the item to this queue.
|
|
60
|
+
*
|
|
61
|
+
* @param item the item to add
|
|
62
|
+
*/
|
|
63
|
+
enqueue(item: T) {
|
|
64
|
+
const oldlast = this.last
|
|
65
|
+
this.last = new QueueNode<T>(item)
|
|
66
|
+
this.last.next = null
|
|
67
|
+
if (this.isEmpty()) this.first = this.last
|
|
68
|
+
else oldlast!.next = this.last
|
|
69
|
+
this.n++
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
/**
|
|
73
|
+
* Removes and returns the item on this queue that was least recently added.
|
|
74
|
+
*
|
|
75
|
+
* @return the item on this queue that was least recently added
|
|
76
|
+
* @throws NoSuchElementException if this queue is empty
|
|
77
|
+
*/
|
|
78
|
+
dequeue() : T {
|
|
79
|
+
if (this.isEmpty()) throw new Error("Queue underflow")
|
|
80
|
+
const item = this.first!.item
|
|
81
|
+
this.first = this.first!.next
|
|
82
|
+
this.n--
|
|
83
|
+
if (this.isEmpty()) this.last = null // to avoid loitering
|
|
84
|
+
return item
|
|
85
|
+
}
|
|
86
|
+
}
|
|
@@ -0,0 +1,216 @@
|
|
|
1
|
+
import { spawn } from "child_process"
|
|
2
|
+
import { existsSync, promises as fs } from "fs"
|
|
3
|
+
import { createSpinner, Spinner } from "nanospinner"
|
|
4
|
+
import { dirname, resolve, sep } from "path"
|
|
5
|
+
import { getLogLevel, log, LOG_LEVEL } from "./log.server"
|
|
6
|
+
import { GitBlobObject, GitTreeObject, ParserData } from "./model"
|
|
7
|
+
import { performance } from "perf_hooks"
|
|
8
|
+
|
|
9
|
+
export function last<T>(array: T[]) {
|
|
10
|
+
return array[array.length - 1]
|
|
11
|
+
}
|
|
12
|
+
|
|
13
|
+
function runProcess(dir: string, command: string, args: string[]) {
|
|
14
|
+
return new Promise((resolve, reject) => {
|
|
15
|
+
try {
|
|
16
|
+
const prcs = spawn(command, args, {
|
|
17
|
+
cwd: dir,
|
|
18
|
+
})
|
|
19
|
+
const chunks: Uint8Array[] = []
|
|
20
|
+
prcs.stderr.once("data", (buf) => reject(buf.toString().trim()))
|
|
21
|
+
prcs.stdout.on("data", (buf) => chunks.push(buf))
|
|
22
|
+
prcs.stdout.on("end", () => {
|
|
23
|
+
resolve(Buffer.concat(chunks).toString().trim())
|
|
24
|
+
})
|
|
25
|
+
} catch (e) {
|
|
26
|
+
reject(e)
|
|
27
|
+
}
|
|
28
|
+
})
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
export async function gitDiffNumStatParsed(
|
|
32
|
+
repo: string,
|
|
33
|
+
a: string,
|
|
34
|
+
b: string,
|
|
35
|
+
renamedFiles: Map<string, string>
|
|
36
|
+
) {
|
|
37
|
+
const diff = await gitDiffNumStat(repo, a, b)
|
|
38
|
+
const entries = diff.split("\n")
|
|
39
|
+
const stuff = entries
|
|
40
|
+
.filter((x) => x.trim().length > 0)
|
|
41
|
+
.map((x) => x.split(/\t+/))
|
|
42
|
+
.map(([neg, pos, file]) => {
|
|
43
|
+
let filePath = file
|
|
44
|
+
const hasBeenMoved = file.includes("=>")
|
|
45
|
+
if (hasBeenMoved) {
|
|
46
|
+
filePath = parseRenamedFile(filePath, renamedFiles)
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
const newestPath = findNewestVersion(filePath, renamedFiles)
|
|
50
|
+
|
|
51
|
+
return {
|
|
52
|
+
neg: parseInt(neg),
|
|
53
|
+
pos: parseInt(pos),
|
|
54
|
+
file: newestPath,
|
|
55
|
+
}
|
|
56
|
+
})
|
|
57
|
+
return stuff
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
function parseRenamedFile(file: string, renamedFiles: Map<string, string>) {
|
|
61
|
+
const movedFileRegex =
|
|
62
|
+
/(?:.*{(?<oldPath>.*)\s=>\s(?<newPath>.*)}.*)|(?:^(?<oldPath2>.*) => (?<newPath2>.*))$/gm
|
|
63
|
+
const replaceRegex = /{.*}/gm
|
|
64
|
+
const match = movedFileRegex.exec(file)
|
|
65
|
+
const groups = match?.groups ?? {}
|
|
66
|
+
|
|
67
|
+
let oldPath: string
|
|
68
|
+
let newPath: string
|
|
69
|
+
|
|
70
|
+
if (groups["oldPath"] || groups["newPath"]) {
|
|
71
|
+
const oldP = groups["oldPath"] ?? ""
|
|
72
|
+
const newP = groups["newPath"] ?? ""
|
|
73
|
+
oldPath = file.replace(replaceRegex, oldP).replace("//", "/")
|
|
74
|
+
newPath = file.replace(replaceRegex, newP).replace("//", "/")
|
|
75
|
+
} else {
|
|
76
|
+
oldPath = groups["oldPath2"] ?? ""
|
|
77
|
+
newPath = groups["newPath2"] ?? ""
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
renamedFiles.set(oldPath, newPath)
|
|
81
|
+
return newPath
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
function findNewestVersion(path: string, renamedFiles: Map<string, string>) {
|
|
85
|
+
let newestPath = path
|
|
86
|
+
let next = renamedFiles.get(newestPath)
|
|
87
|
+
while (next) {
|
|
88
|
+
newestPath = next
|
|
89
|
+
next = renamedFiles.get(newestPath)
|
|
90
|
+
}
|
|
91
|
+
return newestPath
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
export async function lookupFileInTree(
|
|
95
|
+
tree: GitTreeObject,
|
|
96
|
+
path: string
|
|
97
|
+
): Promise<GitBlobObject | undefined> {
|
|
98
|
+
const dirs = path.split("/")
|
|
99
|
+
|
|
100
|
+
if (dirs.length < 2) {
|
|
101
|
+
// We have reached the end of the tree, look for the blob
|
|
102
|
+
const [file] = dirs
|
|
103
|
+
const result = tree.children.find(
|
|
104
|
+
(x) => x.name === file && x.type === "blob"
|
|
105
|
+
)
|
|
106
|
+
if (!result) return
|
|
107
|
+
if (result.type === "tree") return undefined
|
|
108
|
+
return result
|
|
109
|
+
}
|
|
110
|
+
const subtree = tree.children.find((x) => x.name === dirs[0])
|
|
111
|
+
if (!subtree || subtree.type === "blob") return
|
|
112
|
+
return await lookupFileInTree(subtree, dirs.slice(1).join("/"))
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
export async function gitDiffNumStat(repoDir: string, a: string, b: string) {
|
|
116
|
+
const result = await runProcess(repoDir, "git", ["diff", "--numstat", a, b])
|
|
117
|
+
return result as string
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
export async function deflateGitObject(repo: string, hash: string) {
|
|
121
|
+
const result = await runProcess(repo, "git", ["cat-file", "-p", hash])
|
|
122
|
+
return result as string
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
export async function writeRepoToFile(outPath: string, parsedData: ParserData) {
|
|
126
|
+
const data = JSON.stringify(parsedData, null, 2)
|
|
127
|
+
const dir = dirname(outPath)
|
|
128
|
+
if (!existsSync(dir)) {
|
|
129
|
+
await fs.mkdir(dir, { recursive: true })
|
|
130
|
+
}
|
|
131
|
+
await fs.writeFile(outPath, data)
|
|
132
|
+
return outPath
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
export function getRepoName(repoDir: string) {
|
|
136
|
+
return resolve(repoDir).split(sep).slice().reverse()[0]
|
|
137
|
+
}
|
|
138
|
+
|
|
139
|
+
export async function getCurrentBranch(dir: string) {
|
|
140
|
+
const result = (await runProcess(dir, "git", [
|
|
141
|
+
"rev-parse",
|
|
142
|
+
"--abbrev-ref",
|
|
143
|
+
"HEAD",
|
|
144
|
+
])) as string
|
|
145
|
+
return result.trim()
|
|
146
|
+
}
|
|
147
|
+
|
|
148
|
+
export const formatMs = (ms: number) => {
|
|
149
|
+
if (ms < 1000) {
|
|
150
|
+
return `${Math.round(ms)}ms`
|
|
151
|
+
} else {
|
|
152
|
+
return `${(ms / 1000).toFixed(2)}s`
|
|
153
|
+
}
|
|
154
|
+
}
|
|
155
|
+
|
|
156
|
+
export function generateTruckFrames(length: number) {
|
|
157
|
+
const frames = []
|
|
158
|
+
for (let i = 0; i < length; i++) {
|
|
159
|
+
const prefix = " ".repeat(length - i - 1)
|
|
160
|
+
const frame = `${prefix}🚛\n`
|
|
161
|
+
frames.push(frame)
|
|
162
|
+
}
|
|
163
|
+
return frames
|
|
164
|
+
}
|
|
165
|
+
|
|
166
|
+
export function createTruckSpinner() {
|
|
167
|
+
return getLogLevel() === null
|
|
168
|
+
? createSpinner("", {
|
|
169
|
+
interval: 1000 / 20,
|
|
170
|
+
frames: generateTruckFrames(20),
|
|
171
|
+
})
|
|
172
|
+
: null
|
|
173
|
+
}
|
|
174
|
+
|
|
175
|
+
let spinner: null | Spinner = null
|
|
176
|
+
|
|
177
|
+
export async function describeAsyncJob<T>(
|
|
178
|
+
job: () => Promise<T>,
|
|
179
|
+
beforeMsg: string,
|
|
180
|
+
afterMsg: string,
|
|
181
|
+
errorMsg: string
|
|
182
|
+
) {
|
|
183
|
+
spinner = createTruckSpinner()
|
|
184
|
+
const success = (text: string, final = false) => {
|
|
185
|
+
if (getLogLevel() === LOG_LEVEL.SILENT) return
|
|
186
|
+
if (spinner === null) return log.info(text)
|
|
187
|
+
spinner.success({ text })
|
|
188
|
+
if (!final) spinner.start()
|
|
189
|
+
}
|
|
190
|
+
const output = (text: string) => {
|
|
191
|
+
if (spinner) {
|
|
192
|
+
spinner.update({
|
|
193
|
+
text,
|
|
194
|
+
frames: generateTruckFrames(text.length),
|
|
195
|
+
})
|
|
196
|
+
spinner.start()
|
|
197
|
+
} else log.info(text)
|
|
198
|
+
}
|
|
199
|
+
|
|
200
|
+
const error = (text: string) =>
|
|
201
|
+
spinner === null ? log.error(text) : spinner.error({ text })
|
|
202
|
+
|
|
203
|
+
output(beforeMsg)
|
|
204
|
+
try {
|
|
205
|
+
const startTime = performance.now()
|
|
206
|
+
const result = await job()
|
|
207
|
+
const stopTime = performance.now()
|
|
208
|
+
const suffix = `[${formatMs(stopTime - startTime)}]`
|
|
209
|
+
success(`${afterMsg} ${suffix}`, true)
|
|
210
|
+
return result
|
|
211
|
+
} catch (e) {
|
|
212
|
+
error(errorMsg)
|
|
213
|
+
log.error(e as Error)
|
|
214
|
+
process.exit(1)
|
|
215
|
+
}
|
|
216
|
+
}
|