fix(gemini): implement native transactional hooks

This commit is contained in:
ZacharyZhang-NY committed 2026-07-20 08:39:37 -04:00
1 parent 15d044cf63
commit 2dc633a506
17 files changed
+3131 -169

No files matched your search

+229
View File
@@ -0,0 +1,229 @@
package plugins
import (
"bytes"
"fmt"
"reflect"
"sort"
)
type geminiDocument struct {
filePath string
exists bool
raw []byte
root map[string]any
hooks geminiHooks
hookControls geminiHookControls
hasHooksContainer bool
ownedFile bool
}
type geminiRenderedDocument struct {
document *geminiDocument
changed bool
next []byte
remove bool
}
func geminiDocumentLabel(filePath string) string {
return fmt.Sprintf("Gemini CLI user settings at %s", filePath)
}
func parseGeminiDocument(
exists bool,
filePath string,
raw []byte,
) (*geminiDocument, error) {
label := geminiDocumentLabel(filePath)
root, err := decodeJSONCObject(raw, label, false)
if err != nil {
return nil, err
}
hooks, err := readGeminiHooks(root)
if err != nil {
return nil, err
}
controls, err := readGeminiHookControls(root)
if err != nil {
return nil, err
}
_, hasHooks := root["hooks"]
return &geminiDocument{
filePath: filePath, exists: exists, raw: append([]byte(nil), raw...),
root: root, hooks: hooks, hookControls: controls,
hasHooksContainer: hasHooks,
ownedFile: bytes.HasPrefix(raw, []byte(geminiOwnedFileMarker)),
}, nil
}
func createGeminiDocument(filePath string) (*geminiDocument, error) {
return parseGeminiDocument(
false,
filePath,
[]byte(geminiOwnedFileMarker+"\n{}\n"),
)
}
func geminiAlreadyInstalled(
document *geminiDocument,
additions map[string]map[string]any,
) (bool, error) {
if len(additions) != len(geminiManagedEvents) {
return false, nil
}
removals, err := managedGeminiRemovals(document.hooks, "")
if err != nil {
return false, err
}
for _, event := range geminiManagedEvents {
expected, exists := additions[event]
if !exists {
return false, nil
}
removalCount := 0
removeGroup := false
for _, removal := range removals {
if removal.event == event {
removalCount++
removeGroup = removal.removeGroup
}
}
exactCount := 0
for _, group := range document.hooks[event] {
if reflect.DeepEqual(group, expected) {
exactCount++
}
}
if removalCount != 1 || !removeGroup || exactCount != 1 {
return false, nil
}
}
return true, nil
}
func renderGeminiDocument(
document *geminiDocument,
agentID string,
additions map[string]map[string]any,
) (*geminiRenderedDocument, error) {
if agentID == "" && len(additions) > 0 {
installed, err := geminiAlreadyInstalled(document, additions)
if err != nil {
return nil, err
}
if installed {
return &geminiRenderedDocument{document: document}, nil
}
}
label := geminiDocumentLabel(document.filePath)
editor, err := newJSONCEditor(document.raw, label, false)
if err != nil {
return nil, err
}
removals, err := managedGeminiRemovals(document.hooks, agentID)
if err != nil {
return nil, err
}
for _, event := range geminiManagedEvents {
eventRemovals := make([]geminiManagedRemoval, 0)
for _, removal := range removals {
if removal.event == event {
eventRemovals = append(eventRemovals, removal)
}
}
sort.Slice(eventRemovals, func(left, right int) bool {
return eventRemovals[left].groupIndex > eventRemovals[right].groupIndex
})
for _, removal := range eventRemovals {
groupPath := []any{"hooks", event, removal.groupIndex}
if removal.removeGroup {
if err := editor.remove(groupPath); err != nil {
return nil, err
}
continue
}
sort.Sort(sort.Reverse(sort.IntSlice(removal.handlerIndexes)))
for _, handlerIndex := range removal.handlerIndexes {
if err := editor.remove(
appendGeminiPath(groupPath, "hooks", handlerIndex),
); err != nil {
return nil, err
}
}
}
if len(eventRemovals) > 0 {
current, parseErr := parseGeminiDocument(
document.exists,
document.filePath,
editor.pack(),
)
if parseErr != nil {
return nil, parseErr
}
if groups, exists := current.hooks[event]; exists && len(groups) == 0 {
if err := editor.remove([]any{"hooks", event}); err != nil {
return nil, err
}
}
}
}
current, err := parseGeminiDocument(
document.exists,
document.filePath,
editor.pack(),
)
if err != nil {
return nil, err
}
if current.hasHooksContainer && len(current.hooks) == 0 {
if err := editor.remove([]any{"hooks"}); err != nil {
return nil, err
}
}
for _, event := range geminiManagedEvents {
group, exists := additions[event]
if !exists {
continue
}
current, err = parseGeminiDocument(
document.exists,
document.filePath,
editor.pack(),
)
if err != nil {
return nil, err
}
_, eventExists := current.hooks[event]
switch {
case !current.hasHooksContainer:
err = editor.addProperty(nil, "hooks", map[string]any{event: []any{group}})
case eventExists:
err = editor.appendArray([]any{"hooks", event}, group)
default:
err = editor.addProperty([]any{"hooks"}, event, []any{group})
}
if err != nil {
return nil, err
}
}
next := editor.pack()
nextDocument, err := parseGeminiDocument(
document.exists,
document.filePath,
next,
)
if err != nil {
return nil, err
}
remove := len(additions) == 0 && document.ownedFile && len(nextDocument.root) == 0
return &geminiRenderedDocument{
document: document, changed: remove || !bytes.Equal(next, document.raw),
next: next, remove: remove,
}, nil
}
func appendGeminiPath(base []any, parts ...any) []any {
path := make([]any, 0, len(base)+len(parts))
path = append(path, base...)
return append(path, parts...)
}