Skip to content

Commit 10a79b3

Browse files
authored
Merge pull request #36 from kenryu42/feat/add-new-git-rules
feat: block git checkout/switch with --force and --discard-changes flags
2 parents 7cf01eb + 012132b commit 10a79b3

8 files changed

Lines changed: 224 additions & 7 deletions

File tree

dist/bin/cc-safety-net.js

Lines changed: 32 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -2201,7 +2201,7 @@ function stripCommand(tokens) {
22012201
}
22022202
return tokens.slice(i);
22032203
}
2204-
function extractShortOpts(tokens) {
2204+
function extractShortOpts(tokens, options) {
22052205
const opts = new Set;
22062206
let pastDoubleDash = false;
22072207
for (const token of tokens) {
@@ -2217,7 +2217,11 @@ function extractShortOpts(tokens) {
22172217
if (!char || !/[a-zA-Z]/.test(char)) {
22182218
break;
22192219
}
2220-
opts.add(`-${char}`);
2220+
const shortOpt = `-${char}`;
2221+
opts.add(shortOpt);
2222+
if (options?.shortOptsWithValue?.has(shortOpt)) {
2223+
break;
2224+
}
22212225
}
22222226
}
22232227
}
@@ -2524,9 +2528,12 @@ function extractDashCArg(tokens) {
25242528

25252529
// src/core/rules-git.ts
25262530
var REASON_CHECKOUT_DOUBLE_DASH = "git checkout -- discards uncommitted changes permanently. Use 'git stash' first.";
2531+
var REASON_CHECKOUT_FORCE = "git checkout --force discards uncommitted changes. Use 'git stash' first.";
25272532
var REASON_CHECKOUT_REF_PATH = "git checkout <ref> -- <path> overwrites working tree with ref version. Use 'git stash' first.";
25282533
var REASON_CHECKOUT_PATHSPEC_FROM_FILE = "git checkout --pathspec-from-file can overwrite multiple files. Use 'git stash' first.";
25292534
var REASON_CHECKOUT_AMBIGUOUS = "git checkout with multiple positional args may overwrite files. Use 'git switch' for branches or 'git restore' for files.";
2535+
var REASON_SWITCH_DISCARD_CHANGES = "git switch --discard-changes discards uncommitted changes. Use 'git stash' first.";
2536+
var REASON_SWITCH_FORCE = "git switch --force discards uncommitted changes. Use 'git stash' first.";
25302537
var REASON_RESTORE = "git restore discards uncommitted changes. Use 'git stash' first, or use --staged to only unstage.";
25312538
var REASON_RESTORE_WORKTREE = "git restore --worktree explicitly discards working tree changes. Use 'git stash' first.";
25322539
var REASON_RESET_HARD = "git reset --hard destroys all uncommitted changes permanently. Use 'git stash' first.";
@@ -2556,6 +2563,8 @@ var CHECKOUT_OPTS_WITH_VALUE = new Set([
25562563
"--unified"
25572564
]);
25582565
var CHECKOUT_OPTS_WITH_OPTIONAL_VALUE = new Set(["--recurse-submodules", "--track", "-t"]);
2566+
var CHECKOUT_SHORT_OPTS_WITH_VALUE = new Set(["-b", "-B", "-U"]);
2567+
var SWITCH_SHORT_OPTS_WITH_VALUE = new Set(["-c", "-C"]);
25592568
var CHECKOUT_KNOWN_OPTS_NO_VALUE = new Set([
25602569
"-q",
25612570
"--quiet",
@@ -2610,6 +2619,8 @@ function analyzeGit(tokens) {
26102619
switch (subcommand.toLowerCase()) {
26112620
case "checkout":
26122621
return analyzeGitCheckout(rest);
2622+
case "switch":
2623+
return analyzeGitSwitch(rest);
26132624
case "restore":
26142625
return analyzeGitRestore(rest);
26152626
case "reset":
@@ -2667,6 +2678,12 @@ function extractGitSubcommandAndRest(tokens) {
26672678
}
26682679
function analyzeGitCheckout(tokens) {
26692680
const { index: doubleDashIdx, before: beforeDash } = splitAtDoubleDash(tokens);
2681+
const shortOpts = extractShortOpts(beforeDash, {
2682+
shortOptsWithValue: CHECKOUT_SHORT_OPTS_WITH_VALUE
2683+
});
2684+
if (beforeDash.includes("--force") || shortOpts.has("-f")) {
2685+
return REASON_CHECKOUT_FORCE;
2686+
}
26702687
for (const token of tokens) {
26712688
if (token === "-b" || token === "-B" || token === "--orphan") {
26722689
return null;
@@ -2691,6 +2708,19 @@ function analyzeGitCheckout(tokens) {
26912708
}
26922709
return null;
26932710
}
2711+
function analyzeGitSwitch(tokens) {
2712+
const { before } = splitAtDoubleDash(tokens);
2713+
if (before.includes("--discard-changes")) {
2714+
return REASON_SWITCH_DISCARD_CHANGES;
2715+
}
2716+
const shortOpts = extractShortOpts(before, {
2717+
shortOptsWithValue: SWITCH_SHORT_OPTS_WITH_VALUE
2718+
});
2719+
if (before.includes("--force") || shortOpts.has("-f")) {
2720+
return REASON_SWITCH_FORCE;
2721+
}
2722+
return null;
2723+
}
26942724
function getCheckoutPositionalArgs(tokens) {
26952725
const positional = [];
26962726
let i = 0;

dist/core/shell.d.ts

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -10,6 +10,8 @@ export interface WrapperStrippingResult {
1010
}
1111
export declare function stripWrappers(tokens: string[]): string[];
1212
export declare function stripWrappersWithInfo(tokens: string[]): WrapperStrippingResult;
13-
export declare function extractShortOpts(tokens: string[]): Set<string>;
13+
export declare function extractShortOpts(tokens: readonly string[], options?: {
14+
readonly shortOptsWithValue?: ReadonlySet<string>;
15+
}): Set<string>;
1416
export declare function normalizeCommandToken(token: string): string;
1517
export declare function getBasename(token: string): string;

dist/index.js

Lines changed: 32 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1060,7 +1060,7 @@ function stripCommand(tokens) {
10601060
}
10611061
return tokens.slice(i);
10621062
}
1063-
function extractShortOpts(tokens) {
1063+
function extractShortOpts(tokens, options) {
10641064
const opts = new Set;
10651065
let pastDoubleDash = false;
10661066
for (const token of tokens) {
@@ -1076,7 +1076,11 @@ function extractShortOpts(tokens) {
10761076
if (!char || !/[a-zA-Z]/.test(char)) {
10771077
break;
10781078
}
1079-
opts.add(`-${char}`);
1079+
const shortOpt = `-${char}`;
1080+
opts.add(shortOpt);
1081+
if (options?.shortOptsWithValue?.has(shortOpt)) {
1082+
break;
1083+
}
10801084
}
10811085
}
10821086
}
@@ -1383,9 +1387,12 @@ function extractDashCArg(tokens) {
13831387

13841388
// src/core/rules-git.ts
13851389
var REASON_CHECKOUT_DOUBLE_DASH = "git checkout -- discards uncommitted changes permanently. Use 'git stash' first.";
1390+
var REASON_CHECKOUT_FORCE = "git checkout --force discards uncommitted changes. Use 'git stash' first.";
13861391
var REASON_CHECKOUT_REF_PATH = "git checkout <ref> -- <path> overwrites working tree with ref version. Use 'git stash' first.";
13871392
var REASON_CHECKOUT_PATHSPEC_FROM_FILE = "git checkout --pathspec-from-file can overwrite multiple files. Use 'git stash' first.";
13881393
var REASON_CHECKOUT_AMBIGUOUS = "git checkout with multiple positional args may overwrite files. Use 'git switch' for branches or 'git restore' for files.";
1394+
var REASON_SWITCH_DISCARD_CHANGES = "git switch --discard-changes discards uncommitted changes. Use 'git stash' first.";
1395+
var REASON_SWITCH_FORCE = "git switch --force discards uncommitted changes. Use 'git stash' first.";
13891396
var REASON_RESTORE = "git restore discards uncommitted changes. Use 'git stash' first, or use --staged to only unstage.";
13901397
var REASON_RESTORE_WORKTREE = "git restore --worktree explicitly discards working tree changes. Use 'git stash' first.";
13911398
var REASON_RESET_HARD = "git reset --hard destroys all uncommitted changes permanently. Use 'git stash' first.";
@@ -1415,6 +1422,8 @@ var CHECKOUT_OPTS_WITH_VALUE = new Set([
14151422
"--unified"
14161423
]);
14171424
var CHECKOUT_OPTS_WITH_OPTIONAL_VALUE = new Set(["--recurse-submodules", "--track", "-t"]);
1425+
var CHECKOUT_SHORT_OPTS_WITH_VALUE = new Set(["-b", "-B", "-U"]);
1426+
var SWITCH_SHORT_OPTS_WITH_VALUE = new Set(["-c", "-C"]);
14181427
var CHECKOUT_KNOWN_OPTS_NO_VALUE = new Set([
14191428
"-q",
14201429
"--quiet",
@@ -1469,6 +1478,8 @@ function analyzeGit(tokens) {
14691478
switch (subcommand.toLowerCase()) {
14701479
case "checkout":
14711480
return analyzeGitCheckout(rest);
1481+
case "switch":
1482+
return analyzeGitSwitch(rest);
14721483
case "restore":
14731484
return analyzeGitRestore(rest);
14741485
case "reset":
@@ -1526,6 +1537,12 @@ function extractGitSubcommandAndRest(tokens) {
15261537
}
15271538
function analyzeGitCheckout(tokens) {
15281539
const { index: doubleDashIdx, before: beforeDash } = splitAtDoubleDash(tokens);
1540+
const shortOpts = extractShortOpts(beforeDash, {
1541+
shortOptsWithValue: CHECKOUT_SHORT_OPTS_WITH_VALUE
1542+
});
1543+
if (beforeDash.includes("--force") || shortOpts.has("-f")) {
1544+
return REASON_CHECKOUT_FORCE;
1545+
}
15291546
for (const token of tokens) {
15301547
if (token === "-b" || token === "-B" || token === "--orphan") {
15311548
return null;
@@ -1550,6 +1567,19 @@ function analyzeGitCheckout(tokens) {
15501567
}
15511568
return null;
15521569
}
1570+
function analyzeGitSwitch(tokens) {
1571+
const { before } = splitAtDoubleDash(tokens);
1572+
if (before.includes("--discard-changes")) {
1573+
return REASON_SWITCH_DISCARD_CHANGES;
1574+
}
1575+
const shortOpts = extractShortOpts(before, {
1576+
shortOptsWithValue: SWITCH_SHORT_OPTS_WITH_VALUE
1577+
});
1578+
if (before.includes("--force") || shortOpts.has("-f")) {
1579+
return REASON_SWITCH_FORCE;
1580+
}
1581+
return null;
1582+
}
15531583
function getCheckoutPositionalArgs(tokens) {
15541584
const positional = [];
15551585
let i = 0;

src/core/rules-git.ts

Lines changed: 34 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2,12 +2,18 @@ import { extractShortOpts, getBasename } from '@/core/shell';
22

33
const REASON_CHECKOUT_DOUBLE_DASH =
44
"git checkout -- discards uncommitted changes permanently. Use 'git stash' first.";
5+
const REASON_CHECKOUT_FORCE =
6+
"git checkout --force discards uncommitted changes. Use 'git stash' first.";
57
const REASON_CHECKOUT_REF_PATH =
68
"git checkout <ref> -- <path> overwrites working tree with ref version. Use 'git stash' first.";
79
const REASON_CHECKOUT_PATHSPEC_FROM_FILE =
810
"git checkout --pathspec-from-file can overwrite multiple files. Use 'git stash' first.";
911
const REASON_CHECKOUT_AMBIGUOUS =
1012
"git checkout with multiple positional args may overwrite files. Use 'git switch' for branches or 'git restore' for files.";
13+
const REASON_SWITCH_DISCARD_CHANGES =
14+
"git switch --discard-changes discards uncommitted changes. Use 'git stash' first.";
15+
const REASON_SWITCH_FORCE =
16+
"git switch --force discards uncommitted changes. Use 'git stash' first.";
1117
const REASON_RESTORE =
1218
"git restore discards uncommitted changes. Use 'git stash' first, or use --staged to only unstage.";
1319
const REASON_RESTORE_WORKTREE =
@@ -48,6 +54,8 @@ const CHECKOUT_OPTS_WITH_VALUE = new Set([
4854
]);
4955

5056
const CHECKOUT_OPTS_WITH_OPTIONAL_VALUE = new Set(['--recurse-submodules', '--track', '-t']);
57+
const CHECKOUT_SHORT_OPTS_WITH_VALUE = new Set(['-b', '-B', '-U']);
58+
const SWITCH_SHORT_OPTS_WITH_VALUE = new Set(['-c', '-C']);
5159

5260
const CHECKOUT_KNOWN_OPTS_NO_VALUE = new Set([
5361
'-q',
@@ -111,6 +119,8 @@ export function analyzeGit(tokens: readonly string[]): string | null {
111119
switch (subcommand.toLowerCase()) {
112120
case 'checkout':
113121
return analyzeGitCheckout(rest);
122+
case 'switch':
123+
return analyzeGitSwitch(rest);
114124
case 'restore':
115125
return analyzeGitRestore(rest);
116126
case 'reset':
@@ -178,6 +188,13 @@ function extractGitSubcommandAndRest(tokens: readonly string[]): {
178188

179189
function analyzeGitCheckout(tokens: readonly string[]): string | null {
180190
const { index: doubleDashIdx, before: beforeDash } = splitAtDoubleDash(tokens);
191+
const shortOpts = extractShortOpts(beforeDash, {
192+
shortOptsWithValue: CHECKOUT_SHORT_OPTS_WITH_VALUE,
193+
});
194+
195+
if (beforeDash.includes('--force') || shortOpts.has('-f')) {
196+
return REASON_CHECKOUT_FORCE;
197+
}
181198

182199
for (const token of tokens) {
183200
if (token === '-b' || token === '-B' || token === '--orphan') {
@@ -208,6 +225,23 @@ function analyzeGitCheckout(tokens: readonly string[]): string | null {
208225
return null;
209226
}
210227

228+
function analyzeGitSwitch(tokens: readonly string[]): string | null {
229+
const { before } = splitAtDoubleDash(tokens);
230+
231+
if (before.includes('--discard-changes')) {
232+
return REASON_SWITCH_DISCARD_CHANGES;
233+
}
234+
235+
const shortOpts = extractShortOpts(before, {
236+
shortOptsWithValue: SWITCH_SHORT_OPTS_WITH_VALUE,
237+
});
238+
if (before.includes('--force') || shortOpts.has('-f')) {
239+
return REASON_SWITCH_FORCE;
240+
}
241+
242+
return null;
243+
}
244+
211245
function getCheckoutPositionalArgs(tokens: readonly string[]): string[] {
212246
const positional: string[] = [];
213247

src/core/shell.ts

Lines changed: 9 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -790,7 +790,10 @@ function stripCommand(tokens: string[]): string[] {
790790
return tokens.slice(i);
791791
}
792792

793-
export function extractShortOpts(tokens: string[]): Set<string> {
793+
export function extractShortOpts(
794+
tokens: readonly string[],
795+
options?: { readonly shortOptsWithValue?: ReadonlySet<string> },
796+
): Set<string> {
794797
const opts = new Set<string>();
795798
let pastDoubleDash = false;
796799

@@ -807,7 +810,11 @@ export function extractShortOpts(tokens: string[]): Set<string> {
807810
if (!char || !/[a-zA-Z]/.test(char)) {
808811
break;
809812
}
810-
opts.add(`-${char}`);
813+
const shortOpt = `-${char}`;
814+
opts.add(shortOpt);
815+
if (options?.shortOptsWithValue?.has(shortOpt)) {
816+
break;
817+
}
811818
}
812819
}
813820
}

tests/bin/explain/command.test.ts

Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -24,6 +24,18 @@ describe('explainCommand', () => {
2424
expect(result.reason).toContain('git reset --hard');
2525
});
2626

27+
test('git switch --discard-changes returns blocked', () => {
28+
const result = explainCommand('git switch --discard-changes main');
29+
expect(result.result).toBe('blocked');
30+
expect(result.reason).toContain('git switch --discard-changes');
31+
});
32+
33+
test('git switch -f returns blocked', () => {
34+
const result = explainCommand('git switch -f main');
35+
expect(result.result).toBe('blocked');
36+
expect(result.reason).toContain('git switch --force');
37+
});
38+
2739
test('sudo git reset --hard traces wrapper stripping', () => {
2840
const result = explainCommand('sudo git reset --hard');
2941
expect(result.result).toBe('blocked');

tests/core/analyze/parsing-helpers.test.ts

Lines changed: 24 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -82,6 +82,30 @@ describe('shell parsing helpers', () => {
8282
new Set(['-v', '-n']),
8383
);
8484
});
85+
86+
test('stops after short options with attached values when configured', () => {
87+
expect(
88+
extractShortOpts(['git', 'switch', '-cfeature'], {
89+
shortOptsWithValue: new Set(['-c', '-C']),
90+
}),
91+
).toEqual(new Set(['-c']));
92+
expect(
93+
extractShortOpts(['git', 'switch', '-qcfeature'], {
94+
shortOptsWithValue: new Set(['-c', '-C']),
95+
}),
96+
).toEqual(new Set(['-q', '-c']));
97+
expect(
98+
extractShortOpts(['git', 'switch', '-Cfixup'], {
99+
shortOptsWithValue: new Set(['-c', '-C']),
100+
}),
101+
).toEqual(new Set(['-C']));
102+
});
103+
104+
test('accepts readonly token arrays', () => {
105+
const tokens: readonly string[] = ['git', '-v', 'switch', '-f'];
106+
107+
expect(extractShortOpts(tokens)).toEqual(new Set(['-v', '-f']));
108+
});
85109
});
86110

87111
describe('splitShellCommands', () => {

0 commit comments

Comments
 (0)