Skip to content
Open
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
6 changes: 4 additions & 2 deletions src/cmds/git/git.rs
Original file line number Diff line number Diff line change
Expand Up @@ -522,8 +522,10 @@ fn run_log(
// Only add --no-merges if user didn't explicitly request merge commits
let wants_merges = args
.iter()
.any(|arg| arg == "--merges" || arg == "--min-parents=2");
if !wants_merges {
.any(|arg| arg == "--merges" || arg == "--min-parents=2" || arg == "--no-merges");
// Don't add --no-merges if user explicitly requested merges or an exact count (-n N / --max-count)
// When user passes -1 they want 1 commit regardless of whether it's a merge
if !wants_merges && !has_limit_flag {
cmd.arg("--no-merges");
}

Expand Down
53 changes: 53 additions & 0 deletions src/hooks/permissions.rs
Original file line number Diff line number Diff line change
Expand Up @@ -594,6 +594,59 @@ mod tests {
// grant Allow to the entire chain. Every non-empty segment must match
// independently.

// --- Mixed deny/allow chain tests ---
// Deny short-circuits the entire chain regardless of allow rules on other segments.
// This tests that a single denied segment takes priority over a permitted one.

#[test]
fn test_chain_deny_short_circuits_allow() {
// Deny takes precedence: `git reset` is denied, `git status` is allowed.
// Even though `git status` matches allow, the denied `git reset` must
// short-circuit the entire chain to Deny (not Default/Allow/Ask).
let deny = vec!["git reset".to_string()];
let allow = vec!["git status".to_string(), "git *".to_string()];

// Deny segment present → entire chain is Deny
assert_eq!(
check_command_with_rules("git status && git reset --hard", &deny, &[], &allow),
PermissionVerdict::Deny,
"denied segment must short-circuit even with allowed segments present"
);
}

#[test]
fn test_chain_deny_wins_over_partial_allow() {
// Three-segment chain: first allowed, second denied, third allowed.
// Deny short-circuits; result is Deny (not Allow, not Default).
let deny = vec!["git push".to_string()];
let allow = vec!["git status".to_string(), "git log".to_string()];

assert_eq!(
check_command_with_rules(
"git status && git push origin main && git log --oneline -5",
&deny,
&[],
&allow
),
PermissionVerdict::Deny,
"deny must win even when allow rules exist for other segments"
);
}

#[test]
fn test_chain_deny_wins_over_ask() {
// Deny also wins over Ask: `git commit` denied, `git status` would be Ask.
// Precedence is Deny > Ask > Allow > Default.
let deny = vec!["git commit".to_string()];
let ask = vec!["git status".to_string()];

assert_eq!(
check_command_with_rules("git status && git commit -m foo", &deny, &ask, &[]),
PermissionVerdict::Deny,
"deny must win over ask even in compound commands"
);
}

#[test]
fn test_compound_allow_requires_every_segment() {
// Reproduces #1213: `git status` is allowed but `git add .` is not.
Expand Down