threadlines 0.2.21 → 0.2.23

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 (2) hide show
  1. package/dist/git/diff.js +83 -36
  2. package/package.json +1 -1
package/dist/git/diff.js CHANGED
@@ -145,11 +145,22 @@ async function getCommitAuthor(repoRoot, sha) {
145
145
  * Get diff for a PR/MR context in CI environments.
146
146
  *
147
147
  * This is a shared implementation for CI environments that do shallow clones.
148
- * It fetches the target branch on-demand and compares it against HEAD.
148
+ * Uses three-dots logic (merge base) to show only the developer's changes,
149
+ * avoiding drift from main moving forward.
149
150
  *
150
151
  * Strategy:
151
152
  * 1. Fetch target branch: origin/${targetBranch}:refs/remotes/origin/${targetBranch}
152
- * 2. Diff: origin/${targetBranch}..HEAD (two dots = direct comparison)
153
+ * 2. Find merge base (common ancestor) using git merge-base (plumbing command)
154
+ * 3. Fetch merge base commit (always fetch, assume not available)
155
+ * 4. Diff: ${mergeBase}..HEAD (shows only developer's changes, not changes from main)
156
+ *
157
+ * Why three dots (merge base) instead of two dots (direct comparison)?
158
+ * - Two dots: Shows all differences between target branch tip and HEAD
159
+ * - Includes changes that happened in main since branching (drift)
160
+ * - Can show files the developer didn't touch
161
+ * - Three dots: Shows only changes from merge base to HEAD
162
+ * - Shows only what the developer actually changed
163
+ * - Industry standard for "change detection" in PR/MR reviews
153
164
  *
154
165
  * Why HEAD instead of origin/${sourceBranch}?
155
166
  * - CI shallow clones only have HEAD available by default
@@ -175,12 +186,42 @@ async function getPRDiff(repoRoot, targetBranch, logger) {
175
186
  `This is required for PR/MR diff comparison. ` +
176
187
  `Error: ${fetchError instanceof Error ? fetchError.message : 'Unknown error'}`);
177
188
  }
178
- // Use two dots (..) for direct comparison (same as GitHub)
179
- // Two dots: shows all changes in HEAD that aren't in origin/${targetBranch}
180
- // Three dots: requires finding merge base which can fail with shallow clones
181
- logger?.debug(`Comparing origin/${targetBranch}..HEAD`);
182
- const diff = await git.diff([`origin/${targetBranch}..HEAD`, '-U200']);
183
- const diffSummary = await git.diffSummary([`origin/${targetBranch}..HEAD`]);
189
+ // Find merge base (common ancestor) using plumbing command
190
+ // Three dots logic: compare against merge base to show only developer's changes
191
+ // This avoids drift from main moving forward (two dots would include those changes)
192
+ let mergeBase;
193
+ try {
194
+ mergeBase = (0, child_process_1.execSync)(`git merge-base origin/${targetBranch} HEAD`, {
195
+ encoding: 'utf-8',
196
+ cwd: repoRoot
197
+ }).trim();
198
+ if (!mergeBase || mergeBase.length !== 40) {
199
+ throw new Error(`Invalid merge base SHA: "${mergeBase}"`);
200
+ }
201
+ }
202
+ catch (error) {
203
+ const errorMessage = error instanceof Error ? error.message : String(error);
204
+ throw new Error(`Failed to find merge base between origin/${targetBranch} and HEAD. ` +
205
+ `This is required to show only the developer's changes (avoids drift from main). ` +
206
+ `Error: ${errorMessage}`);
207
+ }
208
+ // Always fetch merge base (assume it's not available, fetch is fast/no-op if it is)
209
+ // This ensures we have the merge base available for diff comparison
210
+ logger?.debug(`Fetching merge base: ${mergeBase}`);
211
+ try {
212
+ await git.fetch(['origin', mergeBase, '--depth=1']);
213
+ }
214
+ catch (fetchError) {
215
+ throw new Error(`Failed to fetch merge base ${mergeBase} from origin. ` +
216
+ `This is required for PR/MR diff comparison in shallow clones. ` +
217
+ `Ensure 'origin' remote is configured and accessible. ` +
218
+ `Error: ${fetchError instanceof Error ? fetchError.message : 'Unknown error'}`);
219
+ }
220
+ // Use merge base for diff (three dots logic: shows only developer's changes)
221
+ // git diff is plumbing command, ignores shallow boundaries
222
+ logger?.debug(`Comparing ${mergeBase}..HEAD (merge base vs PR branch)`);
223
+ const diff = await git.diff([`${mergeBase}..HEAD`, '-U200']);
224
+ const diffSummary = await git.diffSummary([`${mergeBase}..HEAD`]);
184
225
  const changedFiles = diffSummary.files.map(f => f.file);
185
226
  return {
186
227
  diff: diff || '',
@@ -190,7 +231,7 @@ async function getPRDiff(repoRoot, targetBranch, logger) {
190
231
  /**
191
232
  * Get diff for a specific commit (or HEAD if no SHA provided).
192
233
  *
193
- * Fetches the parent commit on-demand to ensure git show can generate a proper diff.
234
+ * Uses plumbing commands consistently to work reliably in shallow clones.
194
235
  * This works regardless of CI checkout depth settings (depth=1 or depth=2).
195
236
  *
196
237
  * Strategy:
@@ -199,8 +240,11 @@ async function getPRDiff(repoRoot, targetBranch, logger) {
199
240
  * - Porcelain commands (git show) respect shallow boundaries and hide parents in shallow clones
200
241
  * - This is critical for CI environments that use shallow clones (depth=1)
201
242
  * 2. Parse first parent line (handles standard commits and merge commits)
202
- * 3. Fetch parent commit if available (git fetch origin <parentSHA> --depth=1)
203
- * 4. Use git show to get diff (now parent is available for comparison)
243
+ * 3. Fetch parent commit (git fetch origin <parentSHA> --depth=1)
244
+ * 4. Use git diff <PARENT_SHA> HEAD to get diff (plumbing command, ignores shallow boundaries)
245
+ * - git show HEAD still respects .git/shallow even after fetching parent
246
+ * - git diff <PARENT_SHA> HEAD compares tree objects directly, ignoring shallow boundaries
247
+ * - This is the key fix: must use plumbing commands consistently, not mix with porcelain
204
248
  *
205
249
  * Used by:
206
250
  * - All CI environments for push/commit context (GitHub, GitLab, Bitbucket, Vercel)
@@ -235,9 +279,14 @@ async function getCommitDiff(repoRoot, sha = 'HEAD') {
235
279
  throw new Error(`Commit ${sha} has no parent (it might be the root commit of the repository)`);
236
280
  }
237
281
  // Extract SHA from "parent <sha>" line
238
- parentSha = parentLine.split(' ')[1].trim();
282
+ // Format: "parent <40-char-sha>"
283
+ const parts = parentLine.split(' ');
284
+ if (parts.length < 2 || !parts[1]) {
285
+ throw new Error(`Malformed parent line in commit object: "${parentLine}"`);
286
+ }
287
+ parentSha = parts[1].trim();
239
288
  if (!parentSha || parentSha.length !== 40) {
240
- throw new Error(`Invalid parent SHA format: "${parentSha}"`);
289
+ throw new Error(`Invalid parent SHA format: "${parentSha}" (expected 40 characters)`);
241
290
  }
242
291
  }
243
292
  catch (error) {
@@ -246,33 +295,31 @@ async function getCommitDiff(repoRoot, sha = 'HEAD') {
246
295
  `This is required to generate a proper diff. ` +
247
296
  `Error: ${errorMessage}`);
248
297
  }
249
- // Fetch parent commit (root commits have no parent, so this won't execute for them)
250
- if (parentSha && parentSha.length === 40) {
251
- try {
252
- // Fetch just this one commit (depth=1 is fine, we only need the parent)
253
- await git.fetch(['origin', parentSha, '--depth=1']);
254
- }
255
- catch (error) {
256
- const errorMessage = error instanceof Error ? error.message : String(error);
257
- throw new Error(`Failed to fetch parent commit ${parentSha} from origin. ` +
258
- `This is required to generate a proper diff in shallow clones. ` +
259
- `Ensure 'origin' remote is configured and accessible. ` +
260
- `Error: ${errorMessage}`);
261
- }
298
+ // Fetch parent commit (we've already validated parentSha is valid above)
299
+ // If we get here, parentSha is guaranteed to be a valid 40-character SHA
300
+ try {
301
+ // Fetch just this one commit (depth=1 is fine, we only need the parent)
302
+ await git.fetch(['origin', parentSha, '--depth=1']);
303
+ }
304
+ catch (error) {
305
+ const errorMessage = error instanceof Error ? error.message : String(error);
306
+ throw new Error(`Failed to fetch parent commit ${parentSha} from origin. ` +
307
+ `This is required to generate a proper diff in shallow clones. ` +
308
+ `Ensure 'origin' remote is configured and accessible. ` +
309
+ `Error: ${errorMessage}`);
262
310
  }
263
- // If no parent (root commit), git show will show the full commit content
264
- // This is expected behavior for root commits
265
- // Get diff using git show (now parent should be available)
311
+ // Get diff using plumbing command (git diff) instead of porcelain (git show)
312
+ // git show respects .git/shallow boundaries and will still treat HEAD as root commit
313
+ // git diff <PARENT_SHA> HEAD ignores shallow boundaries and compares tree objects directly
314
+ // This is critical for shallow clones - we must use plumbing commands consistently
266
315
  let diff;
267
316
  let changedFiles;
268
317
  try {
269
- diff = await git.show([sha, '--format=', '--no-color', '-U200']);
270
- // Get changed files using git show --name-only
271
- const commitFiles = await git.show([sha, '--name-only', '--format=', '--pretty=format:']);
272
- changedFiles = commitFiles
273
- .split('\n')
274
- .filter(line => line.trim().length > 0)
275
- .map(line => line.trim());
318
+ // Use git diff to compare parent against HEAD (plumbing command, ignores shallow boundaries)
319
+ diff = await git.diff([`${parentSha}..${sha}`, '-U200']);
320
+ // Get changed files using git diff --name-only
321
+ const diffSummary = await git.diffSummary([`${parentSha}..${sha}`]);
322
+ changedFiles = diffSummary.files.map(f => f.file);
276
323
  }
277
324
  catch (error) {
278
325
  const errorMessage = error instanceof Error ? error.message : 'Unknown error';
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "threadlines",
3
- "version": "0.2.21",
3
+ "version": "0.2.23",
4
4
  "description": "Threadlines CLI - AI-powered linter based on your natural language documentation",
5
5
  "main": "dist/index.js",
6
6
  "bin": {