Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
34 changes: 32 additions & 2 deletions dist/bin/cc-safety-net.js
Original file line number Diff line number Diff line change
Expand Up @@ -2201,7 +2201,7 @@ function stripCommand(tokens) {
}
return tokens.slice(i);
}
function extractShortOpts(tokens) {
function extractShortOpts(tokens, options) {
const opts = new Set;
let pastDoubleDash = false;
for (const token of tokens) {
Expand All @@ -2217,7 +2217,11 @@ function extractShortOpts(tokens) {
if (!char || !/[a-zA-Z]/.test(char)) {
break;
}
opts.add(`-${char}`);
const shortOpt = `-${char}`;
opts.add(shortOpt);
if (options?.shortOptsWithValue?.has(shortOpt)) {
break;
}
}
}
}
Expand Down Expand Up @@ -2524,9 +2528,12 @@ function extractDashCArg(tokens) {

// src/core/rules-git.ts
var REASON_CHECKOUT_DOUBLE_DASH = "git checkout -- discards uncommitted changes permanently. Use 'git stash' first.";
var REASON_CHECKOUT_FORCE = "git checkout --force discards uncommitted changes. Use 'git stash' first.";
var REASON_CHECKOUT_REF_PATH = "git checkout <ref> -- <path> overwrites working tree with ref version. Use 'git stash' first.";
var REASON_CHECKOUT_PATHSPEC_FROM_FILE = "git checkout --pathspec-from-file can overwrite multiple files. Use 'git stash' first.";
var REASON_CHECKOUT_AMBIGUOUS = "git checkout with multiple positional args may overwrite files. Use 'git switch' for branches or 'git restore' for files.";
var REASON_SWITCH_DISCARD_CHANGES = "git switch --discard-changes discards uncommitted changes. Use 'git stash' first.";
var REASON_SWITCH_FORCE = "git switch --force discards uncommitted changes. Use 'git stash' first.";
var REASON_RESTORE = "git restore discards uncommitted changes. Use 'git stash' first, or use --staged to only unstage.";
var REASON_RESTORE_WORKTREE = "git restore --worktree explicitly discards working tree changes. Use 'git stash' first.";
var REASON_RESET_HARD = "git reset --hard destroys all uncommitted changes permanently. Use 'git stash' first.";
Expand Down Expand Up @@ -2556,6 +2563,8 @@ var CHECKOUT_OPTS_WITH_VALUE = new Set([
"--unified"
]);
var CHECKOUT_OPTS_WITH_OPTIONAL_VALUE = new Set(["--recurse-submodules", "--track", "-t"]);
var CHECKOUT_SHORT_OPTS_WITH_VALUE = new Set(["-b", "-B", "-U"]);
var SWITCH_SHORT_OPTS_WITH_VALUE = new Set(["-c", "-C"]);
var CHECKOUT_KNOWN_OPTS_NO_VALUE = new Set([
"-q",
"--quiet",
Expand Down Expand Up @@ -2610,6 +2619,8 @@ function analyzeGit(tokens) {
switch (subcommand.toLowerCase()) {
case "checkout":
return analyzeGitCheckout(rest);
case "switch":
return analyzeGitSwitch(rest);
case "restore":
return analyzeGitRestore(rest);
case "reset":
Expand Down Expand Up @@ -2667,6 +2678,12 @@ function extractGitSubcommandAndRest(tokens) {
}
function analyzeGitCheckout(tokens) {
const { index: doubleDashIdx, before: beforeDash } = splitAtDoubleDash(tokens);
const shortOpts = extractShortOpts(beforeDash, {
shortOptsWithValue: CHECKOUT_SHORT_OPTS_WITH_VALUE
});
if (beforeDash.includes("--force") || shortOpts.has("-f")) {
return REASON_CHECKOUT_FORCE;
}
for (const token of tokens) {
if (token === "-b" || token === "-B" || token === "--orphan") {
return null;
Expand All @@ -2691,6 +2708,19 @@ function analyzeGitCheckout(tokens) {
}
return null;
}
function analyzeGitSwitch(tokens) {
const { before } = splitAtDoubleDash(tokens);
if (before.includes("--discard-changes")) {
return REASON_SWITCH_DISCARD_CHANGES;
}
const shortOpts = extractShortOpts(before, {
shortOptsWithValue: SWITCH_SHORT_OPTS_WITH_VALUE
});
if (before.includes("--force") || shortOpts.has("-f")) {
return REASON_SWITCH_FORCE;
}
return null;
}
function getCheckoutPositionalArgs(tokens) {
const positional = [];
let i = 0;
Expand Down
4 changes: 3 additions & 1 deletion dist/core/shell.d.ts
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,8 @@ export interface WrapperStrippingResult {
}
export declare function stripWrappers(tokens: string[]): string[];
export declare function stripWrappersWithInfo(tokens: string[]): WrapperStrippingResult;
export declare function extractShortOpts(tokens: string[]): Set<string>;
export declare function extractShortOpts(tokens: readonly string[], options?: {
readonly shortOptsWithValue?: ReadonlySet<string>;
}): Set<string>;
export declare function normalizeCommandToken(token: string): string;
export declare function getBasename(token: string): string;
34 changes: 32 additions & 2 deletions dist/index.js
Original file line number Diff line number Diff line change
Expand Up @@ -1060,7 +1060,7 @@ function stripCommand(tokens) {
}
return tokens.slice(i);
}
function extractShortOpts(tokens) {
function extractShortOpts(tokens, options) {
const opts = new Set;
let pastDoubleDash = false;
for (const token of tokens) {
Expand All @@ -1076,7 +1076,11 @@ function extractShortOpts(tokens) {
if (!char || !/[a-zA-Z]/.test(char)) {
break;
}
opts.add(`-${char}`);
const shortOpt = `-${char}`;
opts.add(shortOpt);
if (options?.shortOptsWithValue?.has(shortOpt)) {
break;
}
}
}
}
Expand Down Expand Up @@ -1383,9 +1387,12 @@ function extractDashCArg(tokens) {

// src/core/rules-git.ts
var REASON_CHECKOUT_DOUBLE_DASH = "git checkout -- discards uncommitted changes permanently. Use 'git stash' first.";
var REASON_CHECKOUT_FORCE = "git checkout --force discards uncommitted changes. Use 'git stash' first.";
var REASON_CHECKOUT_REF_PATH = "git checkout <ref> -- <path> overwrites working tree with ref version. Use 'git stash' first.";
var REASON_CHECKOUT_PATHSPEC_FROM_FILE = "git checkout --pathspec-from-file can overwrite multiple files. Use 'git stash' first.";
var REASON_CHECKOUT_AMBIGUOUS = "git checkout with multiple positional args may overwrite files. Use 'git switch' for branches or 'git restore' for files.";
var REASON_SWITCH_DISCARD_CHANGES = "git switch --discard-changes discards uncommitted changes. Use 'git stash' first.";
var REASON_SWITCH_FORCE = "git switch --force discards uncommitted changes. Use 'git stash' first.";
var REASON_RESTORE = "git restore discards uncommitted changes. Use 'git stash' first, or use --staged to only unstage.";
var REASON_RESTORE_WORKTREE = "git restore --worktree explicitly discards working tree changes. Use 'git stash' first.";
var REASON_RESET_HARD = "git reset --hard destroys all uncommitted changes permanently. Use 'git stash' first.";
Expand Down Expand Up @@ -1415,6 +1422,8 @@ var CHECKOUT_OPTS_WITH_VALUE = new Set([
"--unified"
]);
var CHECKOUT_OPTS_WITH_OPTIONAL_VALUE = new Set(["--recurse-submodules", "--track", "-t"]);
var CHECKOUT_SHORT_OPTS_WITH_VALUE = new Set(["-b", "-B", "-U"]);
var SWITCH_SHORT_OPTS_WITH_VALUE = new Set(["-c", "-C"]);
var CHECKOUT_KNOWN_OPTS_NO_VALUE = new Set([
"-q",
"--quiet",
Expand Down Expand Up @@ -1469,6 +1478,8 @@ function analyzeGit(tokens) {
switch (subcommand.toLowerCase()) {
case "checkout":
return analyzeGitCheckout(rest);
case "switch":
return analyzeGitSwitch(rest);
case "restore":
return analyzeGitRestore(rest);
case "reset":
Expand Down Expand Up @@ -1526,6 +1537,12 @@ function extractGitSubcommandAndRest(tokens) {
}
function analyzeGitCheckout(tokens) {
const { index: doubleDashIdx, before: beforeDash } = splitAtDoubleDash(tokens);
const shortOpts = extractShortOpts(beforeDash, {
shortOptsWithValue: CHECKOUT_SHORT_OPTS_WITH_VALUE
});
if (beforeDash.includes("--force") || shortOpts.has("-f")) {
return REASON_CHECKOUT_FORCE;
}
for (const token of tokens) {
if (token === "-b" || token === "-B" || token === "--orphan") {
return null;
Expand All @@ -1550,6 +1567,19 @@ function analyzeGitCheckout(tokens) {
}
return null;
}
function analyzeGitSwitch(tokens) {
const { before } = splitAtDoubleDash(tokens);
if (before.includes("--discard-changes")) {
return REASON_SWITCH_DISCARD_CHANGES;
}
const shortOpts = extractShortOpts(before, {
shortOptsWithValue: SWITCH_SHORT_OPTS_WITH_VALUE
});
if (before.includes("--force") || shortOpts.has("-f")) {
return REASON_SWITCH_FORCE;
}
return null;
}
function getCheckoutPositionalArgs(tokens) {
const positional = [];
let i = 0;
Expand Down
34 changes: 34 additions & 0 deletions src/core/rules-git.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,12 +2,18 @@ import { extractShortOpts, getBasename } from '@/core/shell';

const REASON_CHECKOUT_DOUBLE_DASH =
"git checkout -- discards uncommitted changes permanently. Use 'git stash' first.";
const REASON_CHECKOUT_FORCE =
"git checkout --force discards uncommitted changes. Use 'git stash' first.";
const REASON_CHECKOUT_REF_PATH =
"git checkout <ref> -- <path> overwrites working tree with ref version. Use 'git stash' first.";
const REASON_CHECKOUT_PATHSPEC_FROM_FILE =
"git checkout --pathspec-from-file can overwrite multiple files. Use 'git stash' first.";
const REASON_CHECKOUT_AMBIGUOUS =
"git checkout with multiple positional args may overwrite files. Use 'git switch' for branches or 'git restore' for files.";
const REASON_SWITCH_DISCARD_CHANGES =
"git switch --discard-changes discards uncommitted changes. Use 'git stash' first.";
const REASON_SWITCH_FORCE =
"git switch --force discards uncommitted changes. Use 'git stash' first.";
const REASON_RESTORE =
"git restore discards uncommitted changes. Use 'git stash' first, or use --staged to only unstage.";
const REASON_RESTORE_WORKTREE =
Expand Down Expand Up @@ -48,6 +54,8 @@ const CHECKOUT_OPTS_WITH_VALUE = new Set([
]);

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

const CHECKOUT_KNOWN_OPTS_NO_VALUE = new Set([
'-q',
Expand Down Expand Up @@ -111,6 +119,8 @@ export function analyzeGit(tokens: readonly string[]): string | null {
switch (subcommand.toLowerCase()) {
case 'checkout':
return analyzeGitCheckout(rest);
case 'switch':
return analyzeGitSwitch(rest);
case 'restore':
return analyzeGitRestore(rest);
case 'reset':
Expand Down Expand Up @@ -178,6 +188,13 @@ function extractGitSubcommandAndRest(tokens: readonly string[]): {

function analyzeGitCheckout(tokens: readonly string[]): string | null {
const { index: doubleDashIdx, before: beforeDash } = splitAtDoubleDash(tokens);
const shortOpts = extractShortOpts(beforeDash, {
shortOptsWithValue: CHECKOUT_SHORT_OPTS_WITH_VALUE,
});

if (beforeDash.includes('--force') || shortOpts.has('-f')) {
return REASON_CHECKOUT_FORCE;
}

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

function analyzeGitSwitch(tokens: readonly string[]): string | null {
const { before } = splitAtDoubleDash(tokens);

if (before.includes('--discard-changes')) {
return REASON_SWITCH_DISCARD_CHANGES;
}

const shortOpts = extractShortOpts(before, {
shortOptsWithValue: SWITCH_SHORT_OPTS_WITH_VALUE,
});
if (before.includes('--force') || shortOpts.has('-f')) {
return REASON_SWITCH_FORCE;
}

return null;
}

function getCheckoutPositionalArgs(tokens: readonly string[]): string[] {
const positional: string[] = [];

Expand Down
11 changes: 9 additions & 2 deletions src/core/shell.ts
Original file line number Diff line number Diff line change
Expand Up @@ -790,7 +790,10 @@ function stripCommand(tokens: string[]): string[] {
return tokens.slice(i);
}

export function extractShortOpts(tokens: string[]): Set<string> {
export function extractShortOpts(
tokens: readonly string[],
options?: { readonly shortOptsWithValue?: ReadonlySet<string> },
): Set<string> {
const opts = new Set<string>();
let pastDoubleDash = false;

Expand All @@ -807,7 +810,11 @@ export function extractShortOpts(tokens: string[]): Set<string> {
if (!char || !/[a-zA-Z]/.test(char)) {
break;
}
opts.add(`-${char}`);
const shortOpt = `-${char}`;
opts.add(shortOpt);
if (options?.shortOptsWithValue?.has(shortOpt)) {
break;
}
}
}
}
Expand Down
12 changes: 12 additions & 0 deletions tests/bin/explain/command.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -24,6 +24,18 @@ describe('explainCommand', () => {
expect(result.reason).toContain('git reset --hard');
});

test('git switch --discard-changes returns blocked', () => {
const result = explainCommand('git switch --discard-changes main');
expect(result.result).toBe('blocked');
expect(result.reason).toContain('git switch --discard-changes');
});

test('git switch -f returns blocked', () => {
const result = explainCommand('git switch -f main');
expect(result.result).toBe('blocked');
expect(result.reason).toContain('git switch --force');
});

test('sudo git reset --hard traces wrapper stripping', () => {
const result = explainCommand('sudo git reset --hard');
expect(result.result).toBe('blocked');
Expand Down
24 changes: 24 additions & 0 deletions tests/core/analyze/parsing-helpers.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -82,6 +82,30 @@ describe('shell parsing helpers', () => {
new Set(['-v', '-n']),
);
});

test('stops after short options with attached values when configured', () => {
expect(
extractShortOpts(['git', 'switch', '-cfeature'], {
shortOptsWithValue: new Set(['-c', '-C']),
}),
).toEqual(new Set(['-c']));
expect(
extractShortOpts(['git', 'switch', '-qcfeature'], {
shortOptsWithValue: new Set(['-c', '-C']),
}),
).toEqual(new Set(['-q', '-c']));
expect(
extractShortOpts(['git', 'switch', '-Cfixup'], {
shortOptsWithValue: new Set(['-c', '-C']),
}),
).toEqual(new Set(['-C']));
});

test('accepts readonly token arrays', () => {
const tokens: readonly string[] = ['git', '-v', 'switch', '-f'];

expect(extractShortOpts(tokens)).toEqual(new Set(['-v', '-f']));
});
});

describe('splitShellCommands', () => {
Expand Down
Loading
Loading