Skip to content

Commit 1930c03

Browse files
authored
Merge pull request #4 from stixx/optimizations
Optimize diff generation and add transitive dependency toggle
2 parents fd7393e + e06b003 commit 1930c03

8 files changed

Lines changed: 212 additions & 19 deletions

File tree

README.md

Lines changed: 15 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -134,10 +134,11 @@ jobs:
134134
135135
## Inputs
136136
137-
| Input | Description | Required | Default |
138-
| ---------- | -------------------------------------------------------------- | -------- | ------------------- |
139-
| `base-ref` | Base branch reference for comparison (e.g., `main`, `develop`) | No | `main` |
140-
| `path` | Path to package-lock.json file | No | `package-lock.json` |
137+
| Input | Description | Required | Default |
138+
| -------------------- | -------------------------------------------------------------- | -------- | ------------------- |
139+
| `base-ref` | Base branch reference for comparison (e.g., `main`, `develop`) | No | `main` |
140+
| `path` | Path to package-lock.json file | No | `package-lock.json` |
141+
| `include-transitive` | Include transitive dependencies in the diff | No | `false` |
141142

142143
## Outputs
143144

@@ -216,6 +217,16 @@ To compare against a branch other than `main`:
216217
base-ref: develop
217218
```
218219

220+
### Include Transitive Dependencies
221+
222+
By default, the action only shows changes for direct dependencies to keep the diff clean. To see all changes (including transitive dependencies):
223+
224+
```yaml
225+
- uses: stixx/npm-diff@v1
226+
with:
227+
include-transitive: true
228+
```
229+
219230
## Contributing
220231

221232
Contributions are welcome! Please feel free to submit a Pull Request.

action.yml

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -14,6 +14,10 @@ inputs:
1414
description: 'Path to package-lock.json file'
1515
required: false
1616
default: 'package-lock.json'
17+
include-transitive:
18+
description: 'Include transitive dependencies in the diff'
19+
required: false
20+
default: 'false'
1721

1822
outputs:
1923
has_changes:

dist/index.js

Lines changed: 1 addition & 1 deletion
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

package.json

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
{
22
"name": "npm-diff",
3-
"version": "1.0.3",
3+
"version": "1.1.0",
44
"description": "Generate a formatted diff of npm package-lock.json changes for pull requests",
55
"main": "dist/index.js",
66
"scripts": {

src/action.ts

Lines changed: 59 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -28,15 +28,36 @@ export interface Changes {
2828

2929
export function parseLockfile(
3030
path: string,
31-
contentStr: string | null = null
31+
contentStr: string | null = null,
32+
includeTransitive: boolean = false
3233
): Record<string, PackageInfo> {
3334
try {
3435
const content = contentStr || fs.readFileSync(path, 'utf8');
3536
const data: LockfileData = JSON.parse(content);
3637
let packages: Record<string, PackageInfo | string> = {};
3738

3839
if (data.packages) {
39-
packages = data.packages;
40+
if (includeTransitive) {
41+
packages = { ...data.packages };
42+
} else {
43+
// For lockfile v3, we only want to compare the direct dependencies
44+
// to keep the diff clean and avoid GitHub PR comment limits.
45+
// Direct dependencies are listed in the root package ('')
46+
const rootPackage = data.packages[''] as LockfileData | undefined;
47+
const directDeps = new Set([
48+
...Object.keys(rootPackage?.dependencies || {}),
49+
...Object.keys(rootPackage?.devDependencies || {}),
50+
...Object.keys(rootPackage?.optionalDependencies || {}),
51+
]);
52+
53+
for (const [key, info] of Object.entries(data.packages)) {
54+
if (key === '') continue;
55+
const name = key.replace(/^node_modules\//, '');
56+
if (directDeps.has(name) && !key.includes('/node_modules/')) {
57+
packages[key] = info;
58+
}
59+
}
60+
}
4061
} else {
4162
packages = {
4263
...(data.dependencies || {}),
@@ -77,7 +98,7 @@ export function comparePackages(
7798
for (const pkg of allPackages) {
7899
const inBase = basePackages.has(pkg);
79100
const inHead = headPackages.has(pkg);
80-
const name = pkg.replace('node_modules/', '');
101+
const name = pkg.replace(/^node_modules\//, '');
81102

82103
// Skip packages that have no version (e.g., aliases or metadata without version)
83104
if (inHead && !head[pkg].version) continue;
@@ -100,7 +121,9 @@ export function comparePackages(
100121
}
101122

102123
export function getCompareLink(packageName: string): string {
103-
return `[Compare](https://www.npmjs.com/package/${packageName}?activeTab=versions)`;
124+
// Use only the package name without node_modules path
125+
const name = packageName.split('node_modules/').pop() || packageName;
126+
return `[Compare](https://www.npmjs.com/package/${name}?activeTab=versions)`;
104127
}
105128

106129
export function formatMarkdown(changes: Changes): string {
@@ -145,8 +168,15 @@ export function formatMarkdown(changes: Changes): string {
145168

146169
// Main execution
147170
export function run(): void {
148-
const baseRef = core.getInput('base-ref') || 'main';
149-
const lockfilePath = core.getInput('path') || 'package-lock.json';
171+
const getCustomInput = (name: string) => {
172+
return (
173+
core.getInput(name) || process.env[`INPUT_${name.toUpperCase().replace(/-/g, '_')}`] || ''
174+
);
175+
};
176+
177+
const baseRef = getCustomInput('base-ref') || 'main';
178+
const lockfilePath = getCustomInput('path') || 'package-lock.json';
179+
const includeTransitive = getCustomInput('include-transitive') === 'true';
150180

151181
// Check if lockfile exists
152182
if (!fs.existsSync(lockfilePath)) {
@@ -161,7 +191,11 @@ export function run(): void {
161191
// Get the merge base to ensure we only see changes from the current branch
162192
let baseRevision = `origin/${baseRef}`;
163193
try {
164-
const mergeBase = execSync(`git merge-base "origin/${baseRef}" HEAD`).toString().trim();
194+
const mergeBase = execSync(`git merge-base "origin/${baseRef}" HEAD`, {
195+
maxBuffer: 50 * 1024 * 1024,
196+
})
197+
.toString()
198+
.trim();
165199
if (mergeBase) {
166200
baseRevision = mergeBase;
167201
}
@@ -173,7 +207,9 @@ export function run(): void {
173207

174208
// Check if lockfile changed
175209
try {
176-
const diff = execSync(`git diff --name-only "${baseRevision}" HEAD`).toString();
210+
const diff = execSync(`git diff --name-only "${baseRevision}" HEAD`, {
211+
maxBuffer: 50 * 1024 * 1024,
212+
}).toString();
177213
const changedFiles = diff
178214
.split('\n')
179215
.map((line) => line.trim())
@@ -196,6 +232,17 @@ export function run(): void {
196232
if (err instanceof Error) {
197233
console.error('Error running git diff:', err.message);
198234
}
235+
// If git diff fails (e.g. shallow clone), we don't want to proceed with an empty base
236+
// as it would report all dependencies as added.
237+
core.setOutput('has_changes', 'false');
238+
core.setOutput(
239+
'diff',
240+
`_Error comparing branches. This may be due to a shallow clone. Try setting fetch-depth: 0 in actions/checkout._`
241+
);
242+
core.setOutput('added_count', '0');
243+
core.setOutput('removed_count', '0');
244+
core.setOutput('updated_count', '0');
245+
return;
199246
}
200247

201248
core.setOutput('has_changes', 'true');
@@ -205,13 +252,15 @@ export function run(): void {
205252
try {
206253
baseContent = execSync(`git show "${baseRevision}:${lockfilePath}"`, {
207254
stdio: ['pipe', 'pipe', 'ignore'],
255+
maxBuffer: 50 * 1024 * 1024,
208256
}).toString();
209257
} catch {
210258
// If it fails, baseContent remains '{}'
259+
// This could happen if the file didn't exist in the base branch
211260
}
212261

213-
const base = parseLockfile(lockfilePath, baseContent);
214-
const head = parseLockfile(lockfilePath);
262+
const base = parseLockfile(lockfilePath, baseContent, includeTransitive);
263+
const head = parseLockfile(lockfilePath, null, includeTransitive);
215264
const changes = comparePackages(base, head);
216265
const markdown = formatMarkdown(changes);
217266

test/functional/action.test.js

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -60,7 +60,7 @@ test('Functional test: detects devDependency change in package.json', () => {
6060
const env = {
6161
...process.env,
6262
INPUT_PATH: 'package.json',
63-
'INPUT_BASE-REF': 'main',
63+
INPUT_BASE_REF: 'main',
6464
GITHUB_OUTPUT: outputPath,
6565
GITHUB_WORKSPACE: tempDir,
6666
};
@@ -148,7 +148,7 @@ test('Functional test: detects package upgrade in package-lock.json', () => {
148148
const env = {
149149
...process.env,
150150
// INPUT_PATH defaults to package-lock.json
151-
'INPUT_BASE-REF': 'main',
151+
INPUT_BASE_REF: 'main',
152152
GITHUB_OUTPUT: outputPath,
153153
GITHUB_WORKSPACE: tempDir,
154154
};

test/functional/transitive.test.js

Lines changed: 123 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,123 @@
1+
/* eslint-disable @typescript-eslint/no-require-imports, no-undef */
2+
const { test } = require('node:test');
3+
const assert = require('node:assert');
4+
const { execSync } = require('child_process');
5+
const fs = require('fs');
6+
const path = require('path');
7+
8+
test('Functional test: respects include-transitive input', () => {
9+
const root = process.cwd();
10+
const tempDir = path.join(root, 'temp-functional-test-transitive');
11+
if (fs.existsSync(tempDir)) {
12+
fs.rmSync(tempDir, { recursive: true, force: true });
13+
}
14+
fs.mkdirSync(tempDir);
15+
16+
try {
17+
// Setup git repo
18+
execSync('git init', { cwd: tempDir });
19+
execSync('git config user.email "test@example.com"', { cwd: tempDir });
20+
execSync('git config user.name "Test User"', { cwd: tempDir });
21+
22+
// Create initial package-lock.json (v3) with a transitive dependency
23+
const lockfile = {
24+
name: 'test-pkg',
25+
version: '1.0.0',
26+
lockfileVersion: 3,
27+
requires: true,
28+
packages: {
29+
'': {
30+
name: 'test-pkg',
31+
version: '1.0.0',
32+
dependencies: {
33+
axios: '^1.0.0',
34+
},
35+
},
36+
'node_modules/axios': {
37+
version: '1.0.0',
38+
dependencies: {
39+
follow: '1.0.0',
40+
},
41+
},
42+
'node_modules/follow': {
43+
version: '1.0.0',
44+
},
45+
},
46+
};
47+
fs.writeFileSync(path.join(tempDir, 'package-lock.json'), JSON.stringify(lockfile, null, 2));
48+
execSync('git add package-lock.json', { cwd: tempDir });
49+
execSync('git commit -m "Initial commit"', { cwd: tempDir });
50+
execSync('git branch -m main', { cwd: tempDir });
51+
52+
// Create a new branch and upgrade BOTH direct and transitive dependency
53+
execSync('git checkout -b feature', { cwd: tempDir });
54+
lockfile.packages['node_modules/axios'].version = '1.1.0';
55+
lockfile.packages[''].dependencies.axios = '^1.1.0';
56+
lockfile.packages['node_modules/follow'].version = '1.1.0';
57+
58+
fs.writeFileSync(path.join(tempDir, 'package-lock.json'), JSON.stringify(lockfile, null, 2));
59+
execSync('git add package-lock.json', { cwd: tempDir });
60+
execSync('git commit -m "Upgrade axios and follow"', { cwd: tempDir });
61+
62+
// Add remote origin to self to satisfy git merge-base origin/main
63+
execSync('git remote add origin .', { cwd: tempDir });
64+
execSync('git fetch origin', { cwd: tempDir });
65+
66+
// Prepare GitHub Action environment
67+
const outputPath = path.join(tempDir, 'github_output');
68+
69+
// Test Case 1: include-transitive = false (default)
70+
fs.writeFileSync(outputPath, '');
71+
const envDefault = {
72+
...process.env,
73+
INPUT_BASE_REF: 'main',
74+
INPUT_INCLUDE_TRANSITIVE: 'false',
75+
GITHUB_OUTPUT: outputPath,
76+
GITHUB_WORKSPACE: tempDir,
77+
};
78+
79+
const actionPath = path.join(root, 'src', 'action.ts');
80+
execSync(`node -r ts-node/register ${actionPath}`, {
81+
cwd: tempDir,
82+
env: envDefault,
83+
stdio: 'inherit',
84+
});
85+
86+
let outputContent = fs.readFileSync(outputPath, 'utf8');
87+
console.log('Action Outputs (transitive=false):\n', outputContent);
88+
89+
assert.match(outputContent, /updated_count[\s\S]+1/);
90+
assert.match(outputContent, /axios \| Upgraded/);
91+
assert.ok(
92+
!outputContent.includes('follow | Upgraded'),
93+
'Should NOT include transitive dependency follow'
94+
);
95+
96+
// Test Case 2: include-transitive = true
97+
fs.writeFileSync(outputPath, '');
98+
const envTransitive = {
99+
...process.env,
100+
INPUT_BASE_REF: 'main',
101+
INPUT_INCLUDE_TRANSITIVE: 'true',
102+
GITHUB_OUTPUT: outputPath,
103+
GITHUB_WORKSPACE: tempDir,
104+
};
105+
106+
execSync(`node -r ts-node/register ${actionPath}`, {
107+
cwd: tempDir,
108+
env: envTransitive,
109+
stdio: 'inherit',
110+
});
111+
112+
outputContent = fs.readFileSync(outputPath, 'utf8');
113+
console.log('Action Outputs (transitive=true):\n', outputContent);
114+
115+
assert.match(outputContent, /updated_count[\s\S]+2/);
116+
assert.match(outputContent, /axios \| Upgraded/);
117+
assert.match(outputContent, /follow \| Upgraded/);
118+
} finally {
119+
if (fs.existsSync(tempDir)) {
120+
fs.rmSync(tempDir, { recursive: true, force: true });
121+
}
122+
}
123+
});

test/unit/action.test.js

Lines changed: 7 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -74,7 +74,13 @@ test('parseLockfile handles package.json and devDependencies', () => {
7474
test('parseLockfile handles lockfile v3 packages', () => {
7575
const content = JSON.stringify({
7676
packages: {
77-
'': { name: 'root', version: '1.0.0' },
77+
'': {
78+
name: 'root',
79+
version: '1.0.0',
80+
dependencies: {
81+
pkg: '^1.1.0',
82+
},
83+
},
7884
'node_modules/pkg': { version: '1.1.0' },
7985
},
8086
});

0 commit comments

Comments
 (0)