mirror of
https://github.com/imjasonh/cnotes
synced 2026-07-18 06:39:02 +00:00
Add git notes preservation system with backup/restore functionality
- Added WarnAboutGitNotesLoss hook that warns before destructive git operations - Implemented comprehensive backup/restore system for conversation notes - Added CLI commands: hooks notes backup/restore/list - Configured git to automatically copy notes during rebase operations - Created backup.go with full notes backup and restoration functionality This ensures conversation context is never lost during rebasing, squashing, or other destructive git operations.
This commit is contained in:
parent
d6f6fc5ffe
commit
7b6cdffa8a
4 changed files with 402 additions and 0 deletions
65
internal/handlers/gitrebase.go
Normal file
65
internal/handlers/gitrebase.go
Normal file
|
|
@ -0,0 +1,65 @@
|
|||
package handlers
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"log/slog"
|
||||
"strings"
|
||||
|
||||
"github.com/imjasonh/hooks/internal/hooks"
|
||||
)
|
||||
|
||||
func init() {
|
||||
hooks.RegisterHook(hooks.EventPreToolUse, "Bash", WarnAboutGitNotesLoss)
|
||||
}
|
||||
|
||||
// WarnAboutGitNotesLoss warns users about potential git notes loss during destructive operations
|
||||
func WarnAboutGitNotesLoss(ctx context.Context, input hooks.HookInput) (hooks.HookOutput, error) {
|
||||
bashInput, err := input.GetBashInput()
|
||||
if err != nil {
|
||||
return hooks.HookOutput{Decision: "approve"}, nil
|
||||
}
|
||||
|
||||
command := strings.TrimSpace(bashInput.Command)
|
||||
|
||||
// Check for destructive git operations that might lose notes
|
||||
destructiveOperations := []struct {
|
||||
pattern string
|
||||
warning string
|
||||
}{
|
||||
{
|
||||
pattern: "git rebase",
|
||||
warning: "⚠️ Git rebase can lose conversation notes attached to commits. Consider:\n" +
|
||||
" • Run 'git notes --ref=claude-conversations list' to see which commits have notes\n" +
|
||||
" • Use 'git -c notes.rewrite.mode=copy rebase' to preserve notes\n" +
|
||||
" • Or manually backup notes before rebasing with 'git notes --ref=claude-conversations show <commit>'",
|
||||
},
|
||||
{
|
||||
pattern: "git reset --hard",
|
||||
warning: "⚠️ Hard reset will lose commits and their conversation notes permanently.\n" +
|
||||
" • Consider using 'git reset --soft' or 'git reset --mixed' instead\n" +
|
||||
" • Backup important notes first with 'git notes --ref=claude-conversations show <commit>'",
|
||||
},
|
||||
{
|
||||
pattern: "git commit --amend",
|
||||
warning: "⚠️ Amending commits changes their hash and may lose conversation notes.\n" +
|
||||
" • Notes are attached to the original commit hash\n" +
|
||||
" • Consider creating a new commit instead of amending",
|
||||
},
|
||||
}
|
||||
|
||||
for _, op := range destructiveOperations {
|
||||
if strings.Contains(command, op.pattern) {
|
||||
slog.Info("warning user about potential git notes loss",
|
||||
"command", command,
|
||||
"operation", op.pattern)
|
||||
|
||||
return hooks.HookOutput{
|
||||
Decision: "approve", // Don't block, just warn
|
||||
AdditionalContext: fmt.Sprintf("\n%s\n\nDo you want to proceed with this operation?", op.warning),
|
||||
}, nil
|
||||
}
|
||||
}
|
||||
|
||||
return hooks.HookOutput{Decision: "approve"}, nil
|
||||
}
|
||||
150
internal/notes/backup.go
Normal file
150
internal/notes/backup.go
Normal file
|
|
@ -0,0 +1,150 @@
|
|||
package notes
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"os"
|
||||
"os/exec"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"time"
|
||||
)
|
||||
|
||||
// NotesBackup represents a backup of git notes
|
||||
type NotesBackup struct {
|
||||
BackupTime time.Time `json:"backup_time"`
|
||||
NotesRef string `json:"notes_ref"`
|
||||
Notes map[string]ConversationNote `json:"notes"` // commit_hash -> note
|
||||
}
|
||||
|
||||
// BackupAllNotes creates a backup of all notes in the specified ref
|
||||
func (nm *NotesManager) BackupAllNotes(ctx context.Context) (*NotesBackup, error) {
|
||||
// Get list of all commits with notes
|
||||
cmd := exec.CommandContext(ctx, "git", "notes", "--ref", nm.notesRef, "list")
|
||||
cmd.Dir = nm.workDir
|
||||
|
||||
output, err := cmd.Output()
|
||||
if err != nil {
|
||||
// No notes exist, return empty backup
|
||||
return &NotesBackup{
|
||||
BackupTime: time.Now(),
|
||||
NotesRef: nm.notesRef,
|
||||
Notes: make(map[string]ConversationNote),
|
||||
}, nil
|
||||
}
|
||||
|
||||
backup := &NotesBackup{
|
||||
BackupTime: time.Now(),
|
||||
NotesRef: nm.notesRef,
|
||||
Notes: make(map[string]ConversationNote),
|
||||
}
|
||||
|
||||
// Parse the output to get note SHA and commit SHA pairs
|
||||
lines := strings.Split(strings.TrimSpace(string(output)), "\n")
|
||||
for _, line := range lines {
|
||||
if line == "" {
|
||||
continue
|
||||
}
|
||||
|
||||
// Format is: <note_sha> <commit_sha>
|
||||
parts := strings.Fields(line)
|
||||
if len(parts) != 2 {
|
||||
continue
|
||||
}
|
||||
|
||||
commitHash := parts[1]
|
||||
note, err := nm.GetConversationNote(ctx, commitHash)
|
||||
if err != nil || note == nil {
|
||||
continue
|
||||
}
|
||||
|
||||
backup.Notes[commitHash] = *note
|
||||
}
|
||||
|
||||
return backup, nil
|
||||
}
|
||||
|
||||
// SaveBackupToFile saves a notes backup to a JSON file
|
||||
func (nm *NotesManager) SaveBackupToFile(backup *NotesBackup, filename string) error {
|
||||
data, err := json.MarshalIndent(backup, "", " ")
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to marshal backup: %w", err)
|
||||
}
|
||||
|
||||
if !filepath.IsAbs(filename) {
|
||||
filename = filepath.Join(nm.workDir, filename)
|
||||
}
|
||||
|
||||
return os.WriteFile(filename, data, 0644)
|
||||
}
|
||||
|
||||
// LoadBackupFromFile loads a notes backup from a JSON file
|
||||
func (nm *NotesManager) LoadBackupFromFile(filename string) (*NotesBackup, error) {
|
||||
if !filepath.IsAbs(filename) {
|
||||
filename = filepath.Join(nm.workDir, filename)
|
||||
}
|
||||
|
||||
data, err := os.ReadFile(filename)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to read backup file: %w", err)
|
||||
}
|
||||
|
||||
var backup NotesBackup
|
||||
if err := json.Unmarshal(data, &backup); err != nil {
|
||||
return nil, fmt.Errorf("failed to unmarshal backup: %w", err)
|
||||
}
|
||||
|
||||
return &backup, nil
|
||||
}
|
||||
|
||||
// RestoreNotesFromBackup restores notes from a backup, trying to match them to current commits
|
||||
func (nm *NotesManager) RestoreNotesFromBackup(ctx context.Context, backup *NotesBackup) error {
|
||||
restored := 0
|
||||
skipped := 0
|
||||
|
||||
for commitHash, note := range backup.Notes {
|
||||
// Check if the commit still exists
|
||||
cmd := exec.CommandContext(ctx, "git", "cat-file", "-e", commitHash)
|
||||
cmd.Dir = nm.workDir
|
||||
if err := cmd.Run(); err != nil {
|
||||
// Commit doesn't exist anymore, skip
|
||||
skipped++
|
||||
continue
|
||||
}
|
||||
|
||||
// Check if note already exists
|
||||
if nm.HasConversationNote(ctx, commitHash) {
|
||||
// Note already exists, skip
|
||||
skipped++
|
||||
continue
|
||||
}
|
||||
|
||||
// Restore the note
|
||||
if err := nm.AddConversationNote(ctx, commitHash, note); err != nil {
|
||||
return fmt.Errorf("failed to restore note for commit %s: %w", commitHash, err)
|
||||
}
|
||||
|
||||
restored++
|
||||
}
|
||||
|
||||
fmt.Printf("Notes restoration complete: %d restored, %d skipped\n", restored, skipped)
|
||||
return nil
|
||||
}
|
||||
|
||||
// CreateRebaseBackup creates a timestamped backup before potentially destructive operations
|
||||
func (nm *NotesManager) CreateRebaseBackup(ctx context.Context) (string, error) {
|
||||
backup, err := nm.BackupAllNotes(ctx)
|
||||
if err != nil {
|
||||
return "", fmt.Errorf("failed to create backup: %w", err)
|
||||
}
|
||||
|
||||
timestamp := time.Now().Format("20060102_150405")
|
||||
filename := fmt.Sprintf(".claude-notes-backup-%s.json", timestamp)
|
||||
|
||||
if err := nm.SaveBackupToFile(backup, filename); err != nil {
|
||||
return "", fmt.Errorf("failed to save backup: %w", err)
|
||||
}
|
||||
|
||||
return filename, nil
|
||||
}
|
||||
Loading…
Add table
Add a link
Reference in a new issue