Skip to content

Commit 39c7ac3

Browse files
committed
more logic
1 parent 99584ab commit 39c7ac3

8 files changed

Lines changed: 83 additions & 69 deletions

File tree

src/commands/compare.ts

Lines changed: 45 additions & 54 deletions
Original file line numberDiff line numberDiff line change
@@ -2,7 +2,7 @@ import fs from 'fs';
22
import path from 'path';
33
import { parseEnvFile } from '../core/parseEnv.js';
44
import { diffEnv } from '../core/diffEnv.js';
5-
import { warnIfEnvNotIgnored, isEnvIgnoredByGit } from '../services/git.js';
5+
import { isEnvIgnoredByGit, checkGitignoreStatus } from '../services/git.js';
66
import { findDuplicateKeys } from '../services/duplicates.js';
77
import { filterIgnoredKeys } from '../core/filterIgnoredKeys.js';
88
import type {
@@ -20,6 +20,7 @@ import { printHeader } from '../ui/compare/printHeader.js';
2020
import { printAutoFix } from '../ui/compare/printAutoFix.js';
2121
import { printIssues } from '../ui/compare/printIssues.js';
2222
import { printSuccess } from '../ui/compare/printSuccess.js';
23+
import { printGitignoreWarning } from '../ui/shared/printGitignore.js';
2324

2425
/**
2526
* Compares multiple pairs of .env and .env.example files.
@@ -61,42 +62,11 @@ export async function compareMany(
6162
entry.skipped = { reason: 'missing file' };
6263
opts.collect?.(entry);
6364
continue;
64-
} else {
65-
printHeader(envName, exampleName, opts.json ?? false, skipping);
66-
}
67-
68-
// Git ignore hint (only when not JSON)
69-
let gitignoreUnsafe = false;
70-
let gitignoreMsg: string | null = null;
71-
72-
if (run('gitignore')) {
73-
warnIfEnvNotIgnored({
74-
cwd: opts.cwd,
75-
envFile: envName,
76-
log: (msg) => {
77-
gitignoreUnsafe = true;
78-
gitignoreMsg = msg;
79-
},
80-
});
8165
}
8266

83-
// Duplicate detection (skip entirely if --only excludes it)
84-
let dupsEnv: Array<{ key: string; count: number }> = [];
85-
let dupsEx: Array<{ key: string; count: number }> = [];
86-
if (!opts.allowDuplicates && run('duplicate')) {
87-
dupsEnv = findDuplicateKeys(envPath).filter(
88-
({ key }) =>
89-
!opts.ignore.includes(key) &&
90-
!opts.ignoreRegex.some((rx) => rx.test(key)),
91-
);
92-
dupsEx = findDuplicateKeys(examplePath).filter(
93-
({ key }) =>
94-
!opts.ignore.includes(key) &&
95-
!opts.ignoreRegex.some((rx) => rx.test(key)),
96-
);
97-
}
67+
printHeader(envName, exampleName, opts.json ?? false, skipping);
9868

99-
// Diff + empty
69+
// Parse and filter env files
10070
const currentFull = parseEnvFile(envPath);
10171
const exampleFull = parseEnvFile(examplePath);
10272

@@ -118,12 +88,33 @@ export async function compareMany(
11888
exampleKeys.map((k) => [k, exampleFull[k] ?? '']),
11989
);
12090

91+
// Run checks
12192
const diff = diffEnv(current, example, opts.checkValues);
12293

12394
const emptyKeys = Object.entries(current)
12495
.filter(([, v]) => (v ?? '').trim() === '')
12596
.map(([k]) => k);
12697

98+
let dupsEnv: Array<{ key: string; count: number }> = [];
99+
let dupsEx: Array<{ key: string; count: number }> = [];
100+
if (!opts.allowDuplicates && run('duplicate')) {
101+
dupsEnv = findDuplicateKeys(envPath).filter(
102+
({ key }) =>
103+
!opts.ignore.includes(key) &&
104+
!opts.ignoreRegex.some((rx) => rx.test(key)),
105+
);
106+
dupsEx = findDuplicateKeys(examplePath).filter(
107+
({ key }) =>
108+
!opts.ignore.includes(key) &&
109+
!opts.ignoreRegex.some((rx) => rx.test(key)),
110+
);
111+
}
112+
113+
const gitignoreIssue = run('gitignore')
114+
? checkGitignoreStatus({ cwd: opts.cwd, envFile: envName })
115+
: null;
116+
117+
// Collect filtered results
127118
const filtered = {
128119
missing: run('missing') ? diff.missing : [],
129120
extra: run('extra') ? diff.extra : [],
@@ -132,19 +123,17 @@ export async function compareMany(
132123
run('mismatch') && opts.checkValues ? diff.valueMismatches : [],
133124
duplicatesEnv: run('duplicate') ? dupsEnv : [],
134125
duplicatesEx: run('duplicate') ? dupsEx : [],
135-
gitignoreUnsafe: run('gitignore') ? gitignoreUnsafe : false,
136-
gitignoreMsg: run('gitignore') ? gitignoreMsg : null,
126+
gitignoreIssue,
137127
};
138128

139-
// --- Stats block for compare mode when --show-stats is active ---
129+
// Print stats if requested
140130
if (opts.showStats && !opts.json) {
141131
const envCount = currentKeys.length;
142132
const exampleCount = exampleKeys.length;
143133
const sharedCount = new Set(
144134
currentKeys.filter((k) => exampleKeys.includes(k)),
145135
).size;
146136

147-
// Duplicate "occurrences beyond the first", summed across both files
148137
const duplicateCount = [...dupsEnv, ...dupsEx].reduce(
149138
(acc, { count }) => acc + Math.max(0, count - 1),
150139
0,
@@ -170,14 +159,15 @@ export async function compareMany(
170159
);
171160
}
172161

162+
// Check if all is OK
173163
const allOk =
174164
filtered.missing.length === 0 &&
175165
filtered.extra.length === 0 &&
176166
filtered.empty.length === 0 &&
177167
filtered.duplicatesEnv.length === 0 &&
178168
filtered.duplicatesEx.length === 0 &&
179169
filtered.mismatches.length === 0 &&
180-
!filtered.gitignoreUnsafe;
170+
!filtered.gitignoreIssue;
181171

182172
if (allOk) {
183173
entry.ok = true;
@@ -186,40 +176,46 @@ export async function compareMany(
186176
continue;
187177
}
188178

179+
// Print duplicates
189180
printDuplicates(envName, exampleName, dupsEnv, dupsEx, opts.json ?? false);
190181

182+
// Track errors and update totals
191183
if (filtered.missing.length) {
192184
entry.missing = filtered.missing;
193-
exitWithError = true;
194185
totals.missing += filtered.missing.length;
186+
exitWithError = true;
195187
}
196188
if (filtered.extra.length) {
197189
entry.extra = filtered.extra;
198-
exitWithError = true;
199190
totals.extra += filtered.extra.length;
200191
}
201192
if (filtered.empty.length) {
202193
entry.empty = filtered.empty;
203-
exitWithError = true;
204194
totals.empty += filtered.empty.length;
205195
}
206196
if (filtered.mismatches.length) {
207197
entry.valueMismatches = filtered.mismatches;
208198
totals.mismatch += filtered.mismatches.length;
209-
exitWithError = true;
210199
}
211200
if (filtered.duplicatesEnv.length || filtered.duplicatesEx.length) {
212201
totals.duplicate +=
213202
filtered.duplicatesEnv.length + filtered.duplicatesEx.length;
214-
exitWithError = true;
215203
}
216-
if (filtered.gitignoreUnsafe) {
204+
if (filtered.gitignoreIssue) {
217205
totals.gitignore += 1;
218-
exitWithError = true;
219206
}
220207

208+
// Print all issues
221209
printIssues(filtered, opts.json ?? false);
222210

211+
if (filtered.gitignoreIssue && !opts.json) {
212+
printGitignoreWarning({
213+
envFile: envName,
214+
reason: filtered.gitignoreIssue.reason,
215+
});
216+
}
217+
218+
// Print fix tips if not in JSON mode and not auto-fixing
223219
if (!opts.json && !opts.fix) {
224220
const ignored = isEnvIgnoredByGit({ cwd: opts.cwd, envFile: '.env' });
225221
const envNotIgnored = ignored === false || ignored === null;
@@ -231,6 +227,7 @@ export async function compareMany(
231227
);
232228
}
233229

230+
// Apply auto-fix if requested
234231
if (opts.fix) {
235232
const { changed, result } = applyFixes({
236233
envPath,
@@ -243,15 +240,9 @@ export async function compareMany(
243240
}
244241

245242
opts.collect?.(entry);
246-
const warningsExist =
247-
filtered.extra.length > 0 ||
248-
filtered.empty.length > 0 ||
249-
filtered.duplicatesEnv.length > 0 ||
250-
filtered.duplicatesEx.length > 0 ||
251-
filtered.mismatches.length > 0 ||
252-
filtered.gitignoreUnsafe;
253243

254-
if (opts.strict && warningsExist) {
244+
// In strict mode, any issue (not just errors) causes exit with error
245+
if (opts.strict && !allOk) {
255246
exitWithError = true;
256247
}
257248
}

src/config/types.ts

Lines changed: 0 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -234,6 +234,4 @@ export type Filtered = {
234234
mismatches: Array<{ key: string; expected: string; actual: string }>;
235235
duplicatesEnv: Array<{ key: string; count: number }>;
236236
duplicatesEx: Array<{ key: string; count: number }>;
237-
gitignoreUnsafe: boolean;
238-
gitignoreMsg?: string | null;
239237
};

src/services/git.ts

Lines changed: 28 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -109,6 +109,34 @@ export function warnIfEnvNotIgnored(options: GitignoreCheckOptions = {}): void {
109109
}
110110
}
111111

112+
113+
/**
114+
* Checks if .env file has gitignore issues.
115+
* Returns null if no issue, otherwise returns the reason.
116+
*/
117+
export function checkGitignoreStatus(options: GitignoreCheckOptions = {}): {
118+
reason: 'no-gitignore' | 'not-ignored';
119+
} | null {
120+
const { cwd = process.cwd(), envFile = '.env' } = options;
121+
122+
const envPath = path.resolve(cwd, envFile);
123+
if (!fs.existsSync(envPath)) return null;
124+
if (!isGitRepo(cwd)) return null;
125+
126+
const gitignorePath = path.resolve(cwd, '.gitignore');
127+
128+
if (!fs.existsSync(gitignorePath)) {
129+
return { reason: 'no-gitignore' };
130+
}
131+
132+
const ignored = isEnvIgnoredByGit({ cwd, envFile });
133+
if (ignored === false || ignored === null) {
134+
return { reason: 'not-ignored' };
135+
}
136+
137+
return null;
138+
}
139+
112140
/** Find the git repository root starting from startDir (walk up until ".git"). */
113141
export function findGitRoot(startDir: string): string | null {
114142
let dir = path.resolve(startDir);

src/ui/compare/printIssues.ts

Lines changed: 0 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -37,7 +37,4 @@ export function printIssues(
3737
);
3838
console.log();
3939
}
40-
if (filtered.gitignoreMsg) {
41-
console.log(filtered.gitignoreMsg.replace(/^/gm, ' '));
42-
}
4340
}

test/e2e/cli.autoscan.e2e.test.ts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -85,7 +85,7 @@ describe('no-flag autoscan', () => {
8585
console.log('stdout:', res.stdout);
8686
console.log('stderr:', res.stderr);
8787

88-
expect(res.status).toBe(1);
88+
expect(res.status).toBe(0);
8989
expect(res.stdout).toContain('.env is not ignored by Git');
9090
});
9191

test/e2e/cli.flags.e2e.test.ts

Lines changed: 4 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -227,7 +227,7 @@ describe('duplicate detection', () => {
227227
fs.writeFileSync(path.join(cwd, '.env'), 'FOO=1\nFOO=2\n');
228228
fs.writeFileSync(path.join(cwd, '.env.example'), 'FOO=\n');
229229
const res = runCli(cwd, ['--compare']);
230-
expect(res.status).toBe(1);
230+
expect(res.status).toBe(0);
231231
expect(res.stdout).toContain(
232232
'Duplicate keys in .env (last occurrence wins):',
233233
);
@@ -239,7 +239,7 @@ describe('duplicate detection', () => {
239239
fs.writeFileSync(path.join(cwd, '.env'), 'FOO=1\n');
240240
fs.writeFileSync(path.join(cwd, '.env.example'), 'FOO=\nFOO=\n');
241241
const res = runCli(cwd, ['--compare']);
242-
expect(res.status).toBe(1);
242+
expect(res.status).toBe(0);
243243
expect(res.stdout).toContain(
244244
'Duplicate keys in .env.example (last occurrence wins):',
245245
);
@@ -309,14 +309,14 @@ describe('duplicate detection', () => {
309309
expect(res.stdout).toContain('❌ Missing keys:');
310310
expect(res.stdout).not.toContain('Extra keys');
311311
});
312-
it('fails on flag only extra', () => {
312+
it('warns on flag only extra', () => {
313313
const cwd = tmpDir();
314314
fs.writeFileSync(path.join(cwd, '.env'), 'A=1\nC=2\nD=3\n');
315315
fs.writeFileSync(path.join(cwd, '.env.example'), 'A=\nB=\nC=\n');
316316

317317
const res = runCli(cwd, ['--compare', '--only', 'extra']);
318318

319-
expect(res.status).toBe(1);
319+
expect(res.status).toBe(0);
320320
expect(res.stdout).toContain('Comparing .env ↔ .env.example');
321321
expect(res.stdout).not.toContain('❌ Missing keys:');
322322
expect(res.stdout).toContain('⚠️ Extra keys (not in example):');

test/e2e/cli.ignore.e2e.test.ts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -33,7 +33,7 @@ describe('--ignore and --ignore-regex', () => {
3333
fs.writeFileSync(path.join(cwd, '.env'), 'API_KEY=1\nDEBUG=1\n');
3434
fs.writeFileSync(path.join(cwd, '.env.example'), 'DEBUG=\n');
3535
const res = runCli(cwd, ['--compare']);
36-
expect(res.status).toBe(1);
36+
expect(res.status).toBe(0);
3737
expect(res.stdout).toContain('Extra keys');
3838
expect(res.stdout).toContain('API_KEY');
3939
});

test/e2e/cli.strict.e2e.test.ts

Lines changed: 4 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -69,23 +69,23 @@ describe('--strict mode', () => {
6969
});
7070

7171
describe('--strict mode with --compare', () => {
72-
it('fails on duplicate variables in .env', () => {
72+
it('warns on duplicate variables in .env', () => {
7373
const cwd = tmpDir();
7474
fs.writeFileSync(path.join(cwd, '.env'), 'FOO=1\nFOO=2\n');
7575
fs.writeFileSync(path.join(cwd, '.env.example'), 'FOO=\n');
7676

7777
const res = runCli(cwd, ['--strict', '--compare']);
78-
expect(res.status).toBe(1);
78+
expect(res.status).toBe(0);
7979
expect(res.stdout).toContain('⚠️ Duplicate keys');
8080
});
8181

82-
it('fails on duplicate variables in .env.example', () => {
82+
it('warns on duplicate variables in .env.example', () => {
8383
const cwd = tmpDir();
8484
fs.writeFileSync(path.join(cwd, '.env'), 'FOO=1\n');
8585
fs.writeFileSync(path.join(cwd, '.env.example'), 'FOO=\nFOO=\n');
8686

8787
const res = runCli(cwd, ['--strict', '--compare']);
88-
expect(res.status).toBe(1);
88+
expect(res.status).toBe(0);
8989
expect(res.stdout).toContain('⚠️ Duplicate keys in .env.example');
9090
});
9191
});

0 commit comments

Comments
 (0)