-
Notifications
You must be signed in to change notification settings - Fork 0
Expand file tree
/
Copy pathgit-log-extractor.ts
More file actions
190 lines (166 loc) · 6.34 KB
/
Copy pathgit-log-extractor.ts
File metadata and controls
190 lines (166 loc) · 6.34 KB
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
import { execFile } from "child_process"
import { promisify } from "util"
import path from "path"
import crypto from "crypto"
import type { GitCommitBlock, IGitLogExtractor } from "../interfaces/git"
import { incCodeIndexError } from "../../plugin-runtime.js"
const execFileAsync = promisify(execFile)
/**
* Custom delimiters for parsing `git log` structured output.
*
* Format: %H|||%h|||%an <%ae>|||%aI|||%s|||%b|||ENDCOMMIT
*
* The `|||` separator is chosen because it is extremely unlikely to appear
* in commit messages. `ENDCOMMIT` serves as an explicit commit boundary.
*/
const FIELD_SEPARATOR = "|||"
const COMMIT_TERMINATOR = "ENDCOMMIT"
/**
* Maximum length of commit message content before truncation (4000 chars).
*
* Sized for the smallest common embedding-model context window we expect to
* encounter: `nomic-embed-text` has a 2048-token training-time max, and at
* the conservative English-code ratio of ~2.5 chars/token used by the Ollama
* embedder that yields ~5000 chars of usable budget. 4000 leaves headroom
* for the BOS/EOS tokens and any worse-than-2.5 chars/token ratio that
* punctuation-heavy commit content can trigger.
*
* The Ollama embedder applies a last-resort truncation using the model's
* actual probed context length — see ollama.ts for the defence-in-depth
* guard. This cap is the first line of defence so we don't ship oversized
* items to the embedder in the first place.
*/
const MAX_CONTENT_LENGTH = 4000
/**
* Extracts commit history from a git repository by running `git log`
* with a structured format string and parsing the output.
*/
export class GitLogExtractor implements IGitLogExtractor {
async extractCommits(
workspacePath: string,
maxHistoryDays: number,
maxCommits: number,
branch: string,
): Promise<GitCommitBlock[]> {
const args = this._buildLogArgs(branch, `--since=${maxHistoryDays} days ago`, `--max-count=${maxCommits}`)
return this._executeLog(workspacePath, args)
}
/**
* Extract commits since a specific ISO 8601 date (for incremental indexing).
* @param maxCommits - Hard cap on number of commits returned (safety ceiling).
* @param branch - Git ref (branch name) to index; empty string = HEAD
*/
async extractCommitsSince(
workspacePath: string,
sinceDate: string,
maxCommits: number,
branch: string,
): Promise<GitCommitBlock[]> {
const args = this._buildLogArgs(branch, `--since=${sinceDate}`, `--max-count=${maxCommits}`)
return this._executeLog(workspacePath, args)
}
// --- Private ---
/**
* Build the common `git log` argument array.
* @param branch - Git ref to index; when non-empty, injected as first positional after "log"
* @param sinceArg - The `--since=` argument value
* @param extraArgs - Additional optional args (e.g. `--max-count=N`)
*/
private _buildLogArgs(branch: string, sinceArg: string, ...extraArgs: string[]): string[] {
const formatStr = `%H${FIELD_SEPARATOR}%h${FIELD_SEPARATOR}%an <%ae>${FIELD_SEPARATOR}%aI${FIELD_SEPARATOR}%s${FIELD_SEPARATOR}%b${FIELD_SEPARATOR}${COMMIT_TERMINATOR}`
const refArg = branch.trim().length > 0 ? [branch] : []
return ["log", ...refArg, `--format=${formatStr}`, sinceArg, ...extraArgs, "--encoding=UTF-8"]
}
/**
* Execute `git log` with the given args and parse the output.
*/
private async _executeLog(workspacePath: string, args: string[]): Promise<GitCommitBlock[]> {
// NOTE: --encoding=UTF-8 forces UTF-8 output. Invalid characters are
// replaced with U+FFFD (replacement character) so the parser never
// encounters raw byte sequences it cannot decode.
try {
const { stdout } = await execFileAsync("git", args, {
cwd: workspacePath,
maxBuffer: 50 * 1024 * 1024, // 50MB buffer for large histories
})
if (!stdout || stdout.trim().length === 0) {
return []
}
return this._parseLogOutput(stdout)
} catch (error) {
// Log the error but return empty — the caller (GitIndexManager)
// will handle the missing git case at a higher level.
try {
incCodeIndexError("rag-indexing")
} catch {
// Telemetry may not be initialized yet; swallow silently.
}
throw error
}
}
/**
* Parse the structured `git log` output into GitCommitBlock[].
*
* The output format is:
* ```
* <full_hash>|||<short_hash>|||<author>|||<date>|||<subject>|||<body>|||ENDCOMMIT
* <full_hash>|||<short_hash>|||<author>|||<date>|||<subject>|||<body>|||ENDCOMMIT
* ```
*
* We split on the ENDCOMMIT token and then parse each commit block.
*/
private _parseLogOutput(raw: string): GitCommitBlock[] {
const commits: GitCommitBlock[] = []
// Split by commit terminator and process each block
const blocks = raw.split(COMMIT_TERMINATOR)
for (const block of blocks) {
const trimmed = block.trim()
if (!trimmed) continue
const parsed = this._parseSingleCommit(trimmed)
if (parsed) {
commits.push(parsed)
}
}
return commits
}
/**
* Parse a single commit block from the structured output.
*
* Format per block:
* ```
* <full_hash>|||<short_hash>|||<author>|||<date>|||<subject>|||<body>
* ```
*
* The body may contain newlines; we only split on the first 5 separators
* and treat the rest as the body.
*/
private _parseSingleCommit(block: string): GitCommitBlock | null {
// Split by field separator, limiting to 6 pieces (the last piece is body)
const parts = block.split(FIELD_SEPARATOR, 6)
if (parts.length < 5) {
return null // Not enough fields — malformed entry
}
// The five leading fields are guaranteed by the length check above; the body is
// optional (a commit with no message body).
const [commit_hash = "", short_hash = "", author = "", author_date = "", subject = ""] = parts
// Body is the 6th piece (if present) or empty string
const body = parts.length >= 6 ? (parts[5] ?? "").trim() : ""
// Construct content: subject + "\n\n" + body
const contentRaw = body ? `${subject}\n\n${body}` : subject
// Truncate if too long
const content =
contentRaw.length > MAX_CONTENT_LENGTH ? contentRaw.substring(0, MAX_CONTENT_LENGTH) : contentRaw
// Compute SHA-256 hash of content for cache skipping
const contentHash = crypto.createHash("sha256").update(content, "utf-8").digest("hex")
return {
commit_hash: commit_hash.trim(),
short_hash: short_hash.trim(),
author: author.trim(),
author_date: author_date.trim(),
subject: subject.trim(),
body,
content,
contentHash,
}
}
}