1
0
Fork 0
mirror of https://github.com/imjasonh/cnotes synced 2026-07-08 09:05:17 +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:
Jason Hall 2025-07-21 12:39:13 -04:00
parent d6f6fc5ffe
commit 7b6cdffa8a
Failed to extract signature
4 changed files with 402 additions and 0 deletions

115
cmd/notes.go Normal file
View file

@ -0,0 +1,115 @@
package cmd
import (
"context"
"fmt"
"github.com/imjasonh/hooks/internal/notes"
"github.com/spf13/cobra"
)
var notesCmd = &cobra.Command{
Use: "notes",
Short: "Manage git notes backups and restoration",
Long: `Commands for backing up, restoring, and managing Claude conversation notes.`,
}
var backupCmd = &cobra.Command{
Use: "backup [filename]",
Short: "Backup all conversation notes to a JSON file",
Long: `Creates a backup of all conversation notes attached to commits.
If no filename is provided, creates a timestamped backup file.`,
Args: cobra.MaximumNArgs(1),
RunE: func(cmd *cobra.Command, args []string) error {
ctx := context.Background()
notesManager := notes.NewNotesManager(".")
var filename string
if len(args) > 0 {
filename = args[0]
} else {
var err error
filename, err = notesManager.CreateRebaseBackup(ctx)
if err != nil {
return fmt.Errorf("failed to create backup: %w", err)
}
}
backup, err := notesManager.BackupAllNotes(ctx)
if err != nil {
return fmt.Errorf("failed to backup notes: %w", err)
}
if err := notesManager.SaveBackupToFile(backup, filename); err != nil {
return fmt.Errorf("failed to save backup file: %w", err)
}
fmt.Printf("✅ Backed up %d conversation notes to %s\n", len(backup.Notes), filename)
return nil
},
}
var restoreCmd = &cobra.Command{
Use: "restore <filename>",
Short: "Restore conversation notes from a backup file",
Long: `Restores conversation notes from a previously created backup file.
Only restores notes for commits that still exist and don't already have notes.`,
Args: cobra.ExactArgs(1),
RunE: func(cmd *cobra.Command, args []string) error {
ctx := context.Background()
notesManager := notes.NewNotesManager(".")
filename := args[0]
backup, err := notesManager.LoadBackupFromFile(filename)
if err != nil {
return fmt.Errorf("failed to load backup file: %w", err)
}
fmt.Printf("📄 Loaded backup from %s (%d notes, created %s)\n",
filename, len(backup.Notes), backup.BackupTime.Format("2006-01-02 15:04:05"))
if err := notesManager.RestoreNotesFromBackup(ctx, backup); err != nil {
return fmt.Errorf("failed to restore notes: %w", err)
}
return nil
},
}
var listCmd = &cobra.Command{
Use: "list",
Short: "List all commits with conversation notes",
Long: `Shows all commits that have conversation notes attached.`,
RunE: func(cmd *cobra.Command, args []string) error {
ctx := context.Background()
notesManager := notes.NewNotesManager(".")
backup, err := notesManager.BackupAllNotes(ctx)
if err != nil {
return fmt.Errorf("failed to list notes: %w", err)
}
if len(backup.Notes) == 0 {
fmt.Println("No conversation notes found.")
return nil
}
fmt.Printf("Found %d commits with conversation notes:\n\n", len(backup.Notes))
for commitHash, note := range backup.Notes {
// Get commit subject
fmt.Printf("• %s (%s)\n", commitHash[:8], note.Timestamp.Format("2006-01-02 15:04"))
fmt.Printf(" Session: %s\n", note.SessionID)
fmt.Printf(" Tools: %v\n\n", note.ToolsUsed)
}
fmt.Printf("💡 View notes with: git notes --ref=claude-conversations show <commit>\n")
return nil
},
}
func init() {
rootCmd.AddCommand(notesCmd)
notesCmd.AddCommand(backupCmd)
notesCmd.AddCommand(restoreCmd)
notesCmd.AddCommand(listCmd)
}

View 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
View 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
}

72
test-backup.json Normal file

File diff suppressed because one or more lines are too long