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.
Files changed (74) hide show
  1. package/.eslintrc.json +20 -0
  2. package/.github/workflows/test-and-build.yml +39 -0
  3. package/.husky/pre-commit +4 -0
  4. package/.truckignore +1 -0
  5. package/.vscode/extensions.json +7 -0
  6. package/.vscode/launch.json +24 -0
  7. package/.vscode/settings.json +6 -0
  8. package/LICENSE +21 -0
  9. package/README.md +60 -0
  10. package/app/README.md +46 -0
  11. package/app/entry.client.tsx +4 -0
  12. package/app/entry.server.tsx +27 -0
  13. package/app/parser/.eslintignore +3 -0
  14. package/app/parser/.eslintrc.json +18 -0
  15. package/app/parser/src/TruckIgnore.server.ts +20 -0
  16. package/app/parser/src/constants.ts +1 -0
  17. package/app/parser/src/hydrate.server.ts +199 -0
  18. package/app/parser/src/index.ts +5 -0
  19. package/app/parser/src/log.server.ts +97 -0
  20. package/app/parser/src/model.ts +77 -0
  21. package/app/parser/src/parse.server.ts +276 -0
  22. package/app/parser/src/parse.test.ts +32 -0
  23. package/app/parser/src/queue.ts +86 -0
  24. package/app/parser/src/util.test.ts +8 -0
  25. package/app/parser/src/util.ts +216 -0
  26. package/app/root.tsx +35 -0
  27. package/app/routes/index.tsx +43 -0
  28. package/app/src/authorUnionUtil.test.ts +82 -0
  29. package/app/src/authorUnionUtil.ts +52 -0
  30. package/app/src/components/AuthorDistFragment.tsx +27 -0
  31. package/app/src/components/AuthorDistOther.tsx +24 -0
  32. package/app/src/components/Chart.tsx +362 -0
  33. package/app/src/components/Details.tsx +177 -0
  34. package/app/src/components/EnumSelect.tsx +31 -0
  35. package/app/src/components/GlobalInfo.tsx +17 -0
  36. package/app/src/components/Legend.tsx +65 -0
  37. package/app/src/components/LegendFragment.tsx +29 -0
  38. package/app/src/components/LegendOther.tsx +43 -0
  39. package/app/src/components/Main.tsx +19 -0
  40. package/app/src/components/Options.tsx +24 -0
  41. package/app/src/components/Providers.tsx +121 -0
  42. package/app/src/components/SearchBar.tsx +36 -0
  43. package/app/src/components/SidePanel.tsx +25 -0
  44. package/app/src/components/Spacer.tsx +62 -0
  45. package/app/src/components/Toggle.tsx +21 -0
  46. package/app/src/components/Tooltip.tsx +131 -0
  47. package/app/src/components/util.tsx +150 -0
  48. package/app/src/const.ts +5 -0
  49. package/app/src/contexts/DataContext.ts +12 -0
  50. package/app/src/contexts/MetricContext.ts +14 -0
  51. package/app/src/contexts/OptionsContext.ts +46 -0
  52. package/app/src/contexts/SearchContext.ts +16 -0
  53. package/app/src/extension-color.ts +34 -0
  54. package/app/src/hooks.ts +17 -0
  55. package/app/src/lang-map.d.ts +3 -0
  56. package/app/src/metrics.ts +319 -0
  57. package/app/src/react-app-env.d.ts +1 -0
  58. package/app/src/reportWebVitals.ts +15 -0
  59. package/app/src/setupTests.ts +5 -0
  60. package/app/src/util.ts +33 -0
  61. package/app/styles/App.css +3 -0
  62. package/app/styles/Chart.css +26 -0
  63. package/app/styles/index.css +35 -0
  64. package/app/styles/vars.css +17 -0
  65. package/cli.js +2 -0
  66. package/package.json +99 -0
  67. package/parse.sh +26 -0
  68. package/project-statement.md +43 -0
  69. package/public/favicon.ico +0 -0
  70. package/remix.config.js +21 -0
  71. package/remix.env.d.ts +2 -0
  72. package/server.js +41 -0
  73. package/truckconfig.json +8 -0
  74. 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,8 @@
1
+ import { last } from "./util"
2
+
3
+ describe("last", () => {
4
+ it("gets the last element of the array", () => {
5
+ const arr = [1, 2, 3, 4, 5]
6
+ expect(last(arr)).toBe(5)
7
+ })
8
+ })
@@ -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
+ }