Skip to content

Add dual-mode git-ai operation via global hooks and wrapper#530

Open
svarlamov wants to merge 32 commits intomainfrom
codex/global-hooks-dual-mode
Open

Add dual-mode git-ai operation via global hooks and wrapper#530
svarlamov wants to merge 32 commits intomainfrom
codex/global-hooks-dual-mode

Conversation

@svarlamov
Copy link
Member

@svarlamov svarlamov commented Feb 16, 2026

Summary

  • rewrote hook management to repo-local only (.git/ai/hooks) with persisted repo state in .git/ai/git_hooks_state.json
  • removed git-ai global hook management paths and now rely on safe forwarding to prior repo hooks or global fallback hooks when applicable
  • implemented both-mode child hook virtualization (-c core.hooksPath=<forward-or-null>) so child git does not execute git-ai managed hooks
  • replaced rebase hooksPath mutation with in-place managed-hook masking/restoration (terminal hooks retained, stale-state self-heal)
  • added sequencer-aware fast paths and cherry-pick terminal batching to reduce managed hook churn
  • hardened forwarding recursion guards to reject forwarding into .git/ai and .git-ai paths
  • hardened benchmark harness setup by re-running git-ai git-hooks ensure per copied run repo and asserting local hooksPath/hook surface

Performance Results

Heavy Matrix (baseline = current_wrapper, margin target = <=25%)

Run: benchmark_modes_vs_main.py --iterations-basic 5 --iterations-complex 4 --margin-pct 25 --margin-baseline current_wrapper --enforce-margin

Scenario Wrapper (ms) Hooks (ms) Hooks vs Wrapper Both (ms) Both vs Wrapper Status
commit_human 149.062 182.896 +22.698% 142.994 -4.071% pass
checkpoint_commit_ai 307.771 344.476 +11.926% 305.943 -0.594% pass
reset_mixed_head6 262.999 269.920 +2.632% 258.096 -1.864% pass
stash_roundtrip 174.314 210.041 +20.496% 174.443 +0.074% pass
cherry_pick_three 187.428 272.665 +45.477% 197.004 +5.109% hooks fail
rebase_linear 176.566 173.724 -1.610% 190.119 +7.676% pass
rebase_rebase_merges 434.018 453.332 +4.450% 437.533 +0.810% pass
squash_merge_commit 764.811 817.109 +6.838% 784.600 +2.587% pass

Result: 15/16 checks passing (only cherry_pick_three in hooks mode misses).

10x Nasty Rebase Matrix (cpPython workload)

Run: benchmark_nasty_modes_vs_main.py --feature-commits 900 --main-commits 350 --side-commits 250 --files 6 --lines-per-file 1500 --burst-every 15 --repetitions 1 --margin-pct 25 --margin-baseline current_wrapper --enforce-margin

Scenario Main Wrapper (s) Current Wrapper (s) Hooks (s) Hooks vs Wrapper Both (s) Both vs Wrapper Status
linear 8.423 8.845 8.743 -1.153% 9.270 +4.805% pass
onto 7.465 7.538 7.824 +3.794% 7.681 +1.897% pass
rebase_merges 11.644 11.993 12.183 +1.584% 11.852 -1.176% pass

Result: 6/6 checks passing.

10x Cherry-Pick Stress (targeted)

Workload: 30 sequential cherry-picks (3 reps/mode), with upstream divergence.

Variant Median Time (s) vs Current Wrapper
current_wrapper 0.296 baseline
current_hooks 2.235 +654.986%
current_both 0.287 -3.078%
main_wrapper 1.395 +371.237%

Validation

  • cargo check -q
  • python3 scripts/benchmarks/git/benchmark_modes_vs_main.py --iterations-basic 5 --iterations-complex 4 --margin-pct 25 --margin-baseline current_wrapper --enforce-margin
  • python3 scripts/benchmarks/git/benchmark_nasty_modes_vs_main.py --feature-commits 900 --main-commits 350 --side-commits 250 --files 6 --lines-per-file 1500 --burst-every 15 --repetitions 1 --margin-pct 25 --margin-baseline current_wrapper --enforce-margin
  • targeted 30-commit cherry-pick stress script (3 reps/mode), summary at:
    • /var/folders/6w/760cbpln2cs16fxprwshynb80000gn/T/git-ai-cherrypick-scale-ayt78i0x/summary_cherrypick30.json

Open with Devin

Add hook-name binary dispatch and managed global hooks installation with persisted forward state for previous global/local hooksPath. Add repo-level hook self-heal from checkpoint flow, recursion suppression for internal git subprocesses, and wrapper+hooks coexistence safeguards to avoid double execution. Extend test harness with wrapper/hooks/both modes and add hook mode regression coverage.
@git-ai-cloud
Copy link

git-ai-cloud bot commented Feb 16, 2026

Stats powered by Git AI

🧠 you    ███████████░░░░░░░░░  53%
🤖 ai     ░░░░░░░░░░░█████████  47%
More stats
  • 1.0 lines generated for every 1 accepted
  • 0 seconds waiting for AI
  • Top model: codex::gpt-5.3-codex (1035 accepted lines, 1039 generated lines)

AI code tracked with git-ai

@git-ai-cloud-dev
Copy link

git-ai-cloud-dev bot commented Feb 16, 2026

Stats powered by Git AI

🧠 you    ██████████░░░░░░░░░░  50%
🤖 ai     ░░░░░░░░░░██████████  50%
More stats
  • 0.3 lines generated for every 1 accepted
  • 0 seconds waiting for AI
  • Top model: codex::gpt-5.3-codex (2933 accepted lines, 973 generated lines)

AI code tracked with git-ai

devin-ai-integration[bot]

This comment was marked as resolved.

devin-ai-integration[bot]

This comment was marked as resolved.

devin-ai-integration[bot]

This comment was marked as resolved.

devin-ai-integration[bot]

This comment was marked as resolved.

devin-ai-integration[bot]

This comment was marked as resolved.

devin-ai-integration[bot]

This comment was marked as resolved.

devin-ai-integration[bot]

This comment was marked as resolved.

devin-ai-integration[bot]

This comment was marked as resolved.

devin-ai-integration[bot]

This comment was marked as resolved.

devin-ai-integration[bot]

This comment was marked as resolved.

Comment on lines +25 to +51
thread_local! {
static INTERNAL_GIT_HOOKS_DISABLED_DEPTH: Cell<usize> = const { Cell::new(0) };
}

pub struct InternalGitHooksGuard;

impl Drop for InternalGitHooksGuard {
fn drop(&mut self) {
INTERNAL_GIT_HOOKS_DISABLED_DEPTH.with(|depth| {
let current = depth.get();
if current > 0 {
depth.set(current - 1);
}
});
}
}

/// Disable managed git hooks for internal `git` subprocesses executed through `exec_git*`.
/// Use this guard around higher-level operations that already execute hook logic explicitly.
pub fn disable_internal_git_hooks() -> InternalGitHooksGuard {
INTERNAL_GIT_HOOKS_DISABLED_DEPTH.with(|depth| depth.set(depth.get() + 1));
InternalGitHooksGuard
}

fn should_disable_internal_git_hooks() -> bool {
INTERNAL_GIT_HOOKS_DISABLED_DEPTH.with(|depth| depth.get() > 0)
}
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🚩 disable_internal_git_hooks guard is thread-local — background threads spawned inside hooks won't inherit it

The INTERNAL_GIT_HOOKS_DISABLED_DEPTH counter at src/git/repository.rs:26 is thread_local!. The guard created in run_pre_command_hooks (src/commands/git_handlers.rs:322) and run_post_command_hooks (src/commands/git_handlers.rs:399) only suppresses hooks on the current thread. If any hook logic spawns a background thread that calls exec_git, that thread won't have the guard active and its git subprocesses will run with hooks enabled. Currently, the push hooks (push_hooks::push_pre_command_hook) spawn a background thread to push authorship notes. Those threads call push_authorship_notes which uses exec_git. Since the push happens via exec_git on a different thread, hooks would NOT be suppressed for those internal git calls. In practice this is likely fine because the push operations (git push) are not hook-sensitive commands, but it's a design subtlety worth documenting.

Open in Devin Review

Was this helpful? React with 👍 or 👎 to provide feedback.

Copy link
Contributor

@devin-ai-integration devin-ai-integration bot left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Devin Review found 2 new potential issues.

View 32 additional findings in Devin Review.

Open in Devin Review

Comment on lines +1785 to +1788
"pre-push" => {
let parsed = parsed_invocation("push", hook_args.to_vec());
push_hooks::run_pre_push_hook_managed(&parsed, &repo);
0
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🟡 Hook-mode pre-push cannot detect --dry-run, --delete, or --mirror flags from original push command

In hook mode, the pre-push handler at src/commands/git_hook_handlers.rs:1786 constructs a ParsedGitInvocation using the hook's own arguments (<remote-name> <url>), not the original git push command-line flags. The should_skip_authorship_push check then never sees --dry-run, --delete, or --mirror.

Root Cause

The git pre-push hook receives only <remote-name> and <url> as arguments — it does NOT receive the original push flags. In wrapper mode (src/commands/hooks/push_hooks.rs:8-17), push_pre_command_hook has access to the real parsed_args.command_args which contain the original flags like --dry-run. But in hook mode:

// src/commands/git_hook_handlers.rs:1785-1788
"pre-push" => {
    let parsed = parsed_invocation("push", hook_args.to_vec()); // hook_args = ["origin", "https://..."]
    push_hooks::run_pre_push_hook_managed(&parsed, &repo);
    0
}

run_pre_push_hook_managed calls should_skip_authorship_push(&parsed_args.command_args) at src/commands/hooks/push_hooks.rs:57, which checks for --dry-run, --delete, and --mirror — none of which exist in the hook args.

Impact: When a user runs git push --dry-run, hook mode will still push authorship notes to the remote (a real side-effect during what was supposed to be a no-op). Similarly for --delete and --mirror operations where authorship push should be skipped.

Open in Devin Review

Was this helpful? React with 👍 or 👎 to provide feedback.

Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Acknowledged. This remains intentionally deferred in this changeset per approved scope (Workstreams 1/3/4 only). We did not modify hook-mode pre-push semantics in this patch, so this thread stays open for dedicated follow-up.

devin-ai-integration[bot]

This comment was marked as resolved.

@svarlamov
Copy link
Member Author

Follow-up implemented for Workstreams 1/3/4 only.

  • Fixed Windows hooks disable config in src/git/sync_authorship.rs by replacing hardcoded /dev/null with platform-specific core.hooksPath (NUL on Windows, /dev/null elsewhere).
  • Updated unit tests in sync_authorship.rs to assert the platform-specific hook-path override is present.
  • Validation run completed:
    • cargo test -q
    • cargo test --test push_upstream_authorship
    • cargo test --test hook_modes

Deferred intentionally (out of scope this iteration):

  • Hook-mode pre-push original-flag visibility (--dry-run/--delete/--mirror).

Bug fixes:
- Cache forward hooks dir lookup in handle_git_hook_invocation to avoid
  redundant state file reads (was reading twice per hook invocation)
- Add is_valid_git_oid_or_abbrev for sequencer done file parsing which
  may contain abbreviated commit hashes (>= 7 chars)

Simplifications:
- Extract parse_whitespace_fields to consolidate parse_hook_stdin and
  parse_reference_transaction_stdin which shared identical parsing logic
- Remove 19-line redundant no-op hook match arm in run_managed_hook that
  duplicated hook_has_no_managed_behavior; wildcard already handles this

Tests (18 new unit tests):
- is_valid_git_oid: SHA-1, SHA-256, rejects short/invalid
- is_valid_git_oid_or_abbrev: abbreviated hex acceptance
- parse_reference_transaction_stdin: 3-field extraction, incomplete lines
- parse_hook_stdin: single-field skip, extra field handling
- parse_whitespace_fields: empty input edge cases
- is_path_inside_component: nested segment detection
- is_path_inside_any_git_ai_dir: .git/ai subtree detection
- hook_has_no_managed_behavior: correct classification
- cherry_pick_batch_state: serialization roundtrip
- repo_hook_state: serialization roundtrip
- forward_mode_none: serialization format
- ensure_hook_symlink: idempotency
- rebase_hook_mask: double-enable is noop
- managed/rebase hook name subset invariants

Co-Authored-By: Sasha Varlamov <sasha@sashavarlamov.com>
devin-ai-integration bot and others added 5 commits February 17, 2026 14:10
…anaged_behavior, share git_dir lookup, remove dead code, add tests

Co-Authored-By: Sasha Varlamov <sasha@sashavarlamov.com>
…, fix silent hook breakage in hooks-only mode

Co-Authored-By: Sasha Varlamov <sasha@sashavarlamov.com>
… original exists in forward dir

- Extract sync_non_managed_hook_symlinks to scan forward target dir and only install
  symlinks for non-managed hooks whose original script exists there
- Symlinks point to git-ai binary (not original script) so forwarding preserves
  $0/dirname for Husky-style hooks that resolve relative paths
- Stale symlinks cleaned up when original hook is removed from forward dir
- Self-healing via ensure_repo_hooks_installed re-scans and keeps in sync
- Extract remove_hook_entry helper to deduplicate cleanup logic
- 4 new tests: provisioned-only-when-original-exists, resync cleanup,
  no-forward-target skips non-managed, forward-target with empty dir

Co-Authored-By: Sasha Varlamov <sasha@sashavarlamov.com>
Co-Authored-By: Sasha Varlamov <sasha@sashavarlamov.com>
…dlers-quality

Improve hook handler quality: fix bugs, simplify, add tests
@CLAassistant
Copy link

CLA assistant check
Thank you for your submission! We really appreciate it. Like many open source projects, we ask that you all sign our Contributor License Agreement before we can accept your contribution.
1 out of 2 committers have signed the CLA.

✅ svarlamov
❌ devin-ai-integration[bot]
You have signed the CLA already but the status is still pending? Let us recheck it.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants