@tanagram/cli 0.1.33 → 0.2.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -45,30 +45,58 @@ func ConfigClaude() error {
45
45
  settings["hooks"] = hooks
46
46
  }
47
47
 
48
+ // Check if PreToolUse exists
49
+ preToolUse, preToolUseExist := hooks["PreToolUse"].([]interface{})
50
+ if !preToolUseExist {
51
+ preToolUse = []interface{}{}
52
+ }
53
+
48
54
  // Check if PostToolUse exists
49
55
  postToolUse, postToolUseExist := hooks["PostToolUse"].([]interface{})
50
56
  if !postToolUseExist {
51
57
  postToolUse = []interface{}{}
52
58
  }
53
59
 
54
- // Check if tanagram hook already exists
55
- tanaramExists := false
60
+ // Check if tanagram hooks already exist
61
+ preHookExists := false
62
+ postHookExists := false
63
+
64
+ for _, hook := range preToolUse {
65
+ hookMap, ok := hook.(map[string]interface{})
66
+ if !ok {
67
+ continue
68
+ }
69
+
70
+ if matcher, ok := hookMap["matcher"].(string); ok && matcher == "Edit|Write" {
71
+ innerHooks, ok := hookMap["hooks"].([]interface{})
72
+ if ok {
73
+ for _, innerHook := range innerHooks {
74
+ innerHookMap, ok := innerHook.(map[string]interface{})
75
+ if ok {
76
+ if cmd, ok := innerHookMap["command"].(string); ok && cmd == "tanagram snapshot" {
77
+ preHookExists = true
78
+ break
79
+ }
80
+ }
81
+ }
82
+ }
83
+ }
84
+ }
85
+
56
86
  for _, hook := range postToolUse {
57
87
  hookMap, ok := hook.(map[string]interface{})
58
88
  if !ok {
59
89
  continue
60
90
  }
61
91
 
62
- // Check if this is the Edit|Write matcher
63
92
  if matcher, ok := hookMap["matcher"].(string); ok && matcher == "Edit|Write" {
64
- // Check if tanagram is in the hooks array
65
93
  innerHooks, ok := hookMap["hooks"].([]interface{})
66
94
  if ok {
67
95
  for _, innerHook := range innerHooks {
68
96
  innerHookMap, ok := innerHook.(map[string]interface{})
69
97
  if ok {
70
98
  if cmd, ok := innerHookMap["command"].(string); ok && cmd == "tanagram" {
71
- tanaramExists = true
99
+ postHookExists = true
72
100
  break
73
101
  }
74
102
  }
@@ -77,24 +105,38 @@ func ConfigClaude() error {
77
105
  }
78
106
  }
79
107
 
80
- if tanaramExists {
81
- fmt.Println("✓ Tanagram hook is already configured in ~/.claude/settings.json")
108
+ if preHookExists && postHookExists {
109
+ fmt.Println("✓ Tanagram hooks are already configured in ~/.claude/settings.json")
82
110
  return nil
83
111
  }
84
112
 
85
- // Add tanagram hook
86
- tanaramHook := map[string]interface{}{
87
- "matcher": "Edit|Write",
88
- "hooks": []interface{}{
89
- map[string]interface{}{
90
- "type": "command",
91
- "command": "tanagram",
113
+ if !preHookExists {
114
+ preHook := map[string]interface{}{
115
+ "matcher": "Edit|Write",
116
+ "hooks": []interface{}{
117
+ map[string]interface{}{
118
+ "type": "command",
119
+ "command": "tanagram snapshot",
120
+ },
92
121
  },
93
- },
122
+ }
123
+ preToolUse = append(preToolUse, preHook)
124
+ hooks["PreToolUse"] = preToolUse
94
125
  }
95
126
 
96
- postToolUse = append(postToolUse, tanaramHook)
97
- hooks["PostToolUse"] = postToolUse
127
+ if !postHookExists {
128
+ postHook := map[string]interface{}{
129
+ "matcher": "Edit|Write",
130
+ "hooks": []interface{}{
131
+ map[string]interface{}{
132
+ "type": "command",
133
+ "command": "tanagram",
134
+ },
135
+ },
136
+ }
137
+ postToolUse = append(postToolUse, postHook)
138
+ hooks["PostToolUse"] = postToolUse
139
+ }
98
140
 
99
141
  // Ensure .claude directory exists
100
142
  claudeDir := filepath.Join(home, ".claude")
@@ -112,9 +154,12 @@ func ConfigClaude() error {
112
154
  return fmt.Errorf("failed to write settings: %w", err)
113
155
  }
114
156
 
115
- fmt.Println("✓ Tanagram hook added to ~/.claude/settings.json")
116
- fmt.Println("\nClaude Code will now automatically run Tanagram after Edit/Write operations.")
117
- fmt.Println("Any policy violations will be sent to Claude for automatic fixing.")
157
+ fmt.Println("✓ Tanagram hooks added to ~/.claude/settings.json")
158
+ fmt.Println("\nClaude Code will now:")
159
+ fmt.Println(" - Snapshot file state before each Edit/Write (PreToolUse)")
160
+ fmt.Println(" - Check only Claude's changes after Edit/Write (PostToolUse)")
161
+ fmt.Println(" - Send policy violations to Claude for automatic fixing")
162
+ fmt.Println("\nThis prevents false positives from user-written code!")
118
163
 
119
164
  return nil
120
165
  }
@@ -148,95 +193,120 @@ func ConfigList() error {
148
193
 
149
194
  // HookStatus represents the status of a hook configuration
150
195
  type HookStatus struct {
151
- FileExists bool
152
- HookExists bool
153
- IsUpToDate bool
154
- Command string
155
- Error error
196
+ FileExists bool
197
+ PreHookExists bool
198
+ PostHookExists bool
199
+ IsUpToDate bool
200
+ PreCommand string
201
+ PostCommand string
202
+ Error error
156
203
  }
157
204
 
158
205
  // checkHookStatus checks the status of Tanagram hook in a settings file
159
206
  func checkHookStatus(settingsPath string) HookStatus {
160
207
  status := HookStatus{
161
- FileExists: false,
162
- HookExists: false,
163
- IsUpToDate: false,
164
- Command: "",
165
- Error: nil,
208
+ FileExists: false,
209
+ PreHookExists: false,
210
+ PostHookExists: false,
211
+ IsUpToDate: false,
212
+ PreCommand: "",
213
+ PostCommand: "",
214
+ Error: nil,
166
215
  }
167
216
 
168
- // Check if file exists
169
217
  if _, err := os.Stat(settingsPath); os.IsNotExist(err) {
170
218
  return status
171
219
  }
172
220
 
173
221
  status.FileExists = true
174
222
 
175
- // Read file
176
223
  data, err := os.ReadFile(settingsPath)
177
224
  if err != nil {
178
225
  status.Error = err
179
226
  return status
180
227
  }
181
228
 
182
- // Parse JSON
183
229
  var settings map[string]interface{}
184
230
  if err := json.Unmarshal(data, &settings); err != nil {
185
231
  status.Error = fmt.Errorf("invalid JSON: %w", err)
186
232
  return status
187
233
  }
188
234
 
189
- // Check for hooks
190
235
  hooks, ok := settings["hooks"].(map[string]interface{})
191
236
  if !ok {
192
237
  return status
193
238
  }
194
239
 
195
- postToolUse, ok := hooks["PostToolUse"].([]interface{})
196
- if !ok {
197
- return status
198
- }
240
+ if preToolUse, ok := hooks["PreToolUse"].([]interface{}); ok {
241
+ for _, hook := range preToolUse {
242
+ hookMap, ok := hook.(map[string]interface{})
243
+ if !ok {
244
+ continue
245
+ }
199
246
 
200
- // Look for tanagram hook
201
- for _, hook := range postToolUse {
202
- hookMap, ok := hook.(map[string]interface{})
203
- if !ok {
204
- continue
205
- }
247
+ matcher, ok := hookMap["matcher"].(string)
248
+ if !ok || matcher != "Edit|Write" {
249
+ continue
250
+ }
206
251
 
207
- // Check if this is the Edit|Write matcher
208
- matcher, ok := hookMap["matcher"].(string)
209
- if !ok {
210
- continue
211
- }
252
+ innerHooks, ok := hookMap["hooks"].([]interface{})
253
+ if !ok {
254
+ continue
255
+ }
212
256
 
213
- // Check if tanagram is in the hooks array
214
- innerHooks, ok := hookMap["hooks"].([]interface{})
215
- if !ok {
216
- continue
257
+ for _, innerHook := range innerHooks {
258
+ innerHookMap, ok := innerHook.(map[string]interface{})
259
+ if !ok {
260
+ continue
261
+ }
262
+
263
+ cmd, cmdOk := innerHookMap["command"].(string)
264
+ if cmdOk && strings.Contains(cmd, "tanagram snapshot") {
265
+ status.PreHookExists = true
266
+ status.PreCommand = cmd
267
+ }
268
+ }
217
269
  }
270
+ }
271
+
272
+ if postToolUse, ok := hooks["PostToolUse"].([]interface{}); ok {
273
+ for _, hook := range postToolUse {
274
+ hookMap, ok := hook.(map[string]interface{})
275
+ if !ok {
276
+ continue
277
+ }
278
+
279
+ matcher, ok := hookMap["matcher"].(string)
280
+ if !ok || matcher != "Edit|Write" {
281
+ continue
282
+ }
218
283
 
219
- for _, innerHook := range innerHooks {
220
- innerHookMap, ok := innerHook.(map[string]interface{})
284
+ innerHooks, ok := hookMap["hooks"].([]interface{})
221
285
  if !ok {
222
286
  continue
223
287
  }
224
288
 
225
- cmd, cmdOk := innerHookMap["command"].(string)
226
- hookType, typeOk := innerHookMap["type"].(string)
289
+ for _, innerHook := range innerHooks {
290
+ innerHookMap, ok := innerHook.(map[string]interface{})
291
+ if !ok {
292
+ continue
293
+ }
227
294
 
228
- if cmdOk && strings.Contains(cmd, "tanagram") {
229
- status.HookExists = true
230
- status.Command = cmd
295
+ cmd, cmdOk := innerHookMap["command"].(string)
296
+ hookType, typeOk := innerHookMap["type"].(string)
231
297
 
232
- // Check if it's up to date (should be "tanagram" and type "command" and matcher "Edit|Write")
233
- if cmd == "tanagram" && typeOk && hookType == "command" && matcher == "Edit|Write" {
234
- status.IsUpToDate = true
298
+ if cmdOk && cmd == "tanagram" && typeOk && hookType == "command" {
299
+ status.PostHookExists = true
300
+ status.PostCommand = cmd
235
301
  }
236
302
  }
237
303
  }
238
304
  }
239
305
 
306
+ if status.PreHookExists && status.PostHookExists {
307
+ status.IsUpToDate = true
308
+ }
309
+
240
310
  return status
241
311
  }
242
312
 
@@ -253,7 +323,7 @@ func printHookStatus(status HookStatus, path string) {
253
323
  return
254
324
  }
255
325
 
256
- if !status.HookExists {
326
+ if !status.PreHookExists && !status.PostHookExists {
257
327
  fmt.Printf(" ○ Not configured\n")
258
328
  fmt.Printf(" → Run: tanagram config claude\n")
259
329
  return
@@ -261,12 +331,21 @@ func printHookStatus(status HookStatus, path string) {
261
331
 
262
332
  if status.IsUpToDate {
263
333
  fmt.Printf(" ✓ Configured and up to date\n")
264
- fmt.Printf(" Command: %s\n", status.Command)
334
+ fmt.Printf(" PreToolUse: %s\n", status.PreCommand)
335
+ fmt.Printf(" PostToolUse: %s\n", status.PostCommand)
265
336
  fmt.Printf(" Location: %s\n", path)
266
337
  } else {
267
- fmt.Printf(" ⚠ Configured but outdated\n")
268
- fmt.Printf(" Current: %s\n", status.Command)
269
- fmt.Printf(" Expected: tanagram\n")
338
+ fmt.Printf(" ⚠ Configured but incomplete\n")
339
+ if status.PreHookExists {
340
+ fmt.Printf(" ✓ PreToolUse: %s\n", status.PreCommand)
341
+ } else {
342
+ fmt.Printf(" ✗ PreToolUse: missing\n")
343
+ }
344
+ if status.PostHookExists {
345
+ fmt.Printf(" ✓ PostToolUse: %s\n", status.PostCommand)
346
+ } else {
347
+ fmt.Printf(" ✗ PostToolUse: missing\n")
348
+ }
270
349
  fmt.Printf(" → Run: tanagram config claude\n")
271
350
  }
272
351
  }
package/commands/run.go CHANGED
@@ -14,6 +14,7 @@ import (
14
14
  "github.com/tanagram/cli/git"
15
15
  "github.com/tanagram/cli/metrics"
16
16
  "github.com/tanagram/cli/parser"
17
+ "github.com/tanagram/cli/snapshot"
17
18
  "github.com/tanagram/cli/storage"
18
19
  )
19
20
 
@@ -197,19 +198,68 @@ func Run() error {
197
198
 
198
199
  fmt.Printf("Loaded %d policies\n", len(policies))
199
200
 
200
- // Get all changes (unstaged + staged)
201
- fmt.Println("Checking all changes (unstaged + staged)...")
202
- diffResult, err := git.GetAllChanges()
203
- if err != nil {
204
- return fmt.Errorf("error getting git diff: %w", err)
201
+ // Check if a snapshot exists (from PreToolUse hook)
202
+ var changesToCheck []git.ChangedLine
203
+ useSnapshot := false
204
+
205
+ if snapshot.Exists(gitRoot) {
206
+ fmt.Println("Snapshot detected - checking only Claude's changes...")
207
+
208
+ // Load snapshot
209
+ snap, err := snapshot.Load(gitRoot)
210
+ if err != nil {
211
+ return fmt.Errorf("failed to load snapshot: %w", err)
212
+ }
213
+
214
+ // Compare current state to snapshot
215
+ compareResult, err := snapshot.Compare(gitRoot, snap)
216
+ if err != nil {
217
+ return fmt.Errorf("failed to compare to snapshot: %w", err)
218
+ }
219
+
220
+ // Get changed lines
221
+ snapshotChanges, err := snapshot.GetChangedLinesForChecker(gitRoot, snap, compareResult)
222
+ if err != nil {
223
+ return fmt.Errorf("failed to get changed lines from snapshot: %w", err)
224
+ }
225
+
226
+ // Convert snapshot.ChangedLine to git.ChangedLine
227
+ for _, sc := range snapshotChanges {
228
+ changesToCheck = append(changesToCheck, git.ChangedLine{
229
+ File: sc.File,
230
+ LineNumber: sc.LineNumber,
231
+ Content: sc.Content,
232
+ ChangeType: sc.ChangeType,
233
+ })
234
+ }
235
+
236
+ // Delete snapshot after using it
237
+ if err := snapshot.Delete(gitRoot); err != nil {
238
+ fmt.Fprintf(os.Stderr, "Warning: failed to delete snapshot: %v\n", err)
239
+ }
240
+
241
+ useSnapshot = true
242
+ } else {
243
+ // No snapshot - fall back to checking all git changes
244
+ fmt.Println("Checking all changes (unstaged + staged)...")
245
+ diffResult, err := git.GetAllChanges()
246
+ if err != nil {
247
+ return fmt.Errorf("error getting git diff: %w", err)
248
+ }
249
+
250
+ changesToCheck = diffResult.Changes
205
251
  }
206
252
 
207
- if len(diffResult.Changes) == 0 {
208
- fmt.Println("No changes to check")
253
+ if len(changesToCheck) == 0 {
254
+ if useSnapshot {
255
+ fmt.Println("No changes detected since snapshot")
256
+ } else {
257
+ fmt.Println("No changes to check")
258
+ }
209
259
  return nil
210
260
  }
211
261
 
212
- fmt.Printf("Scanning %d changed lines...\n\n", len(diffResult.Changes))
262
+ fmt.Printf("Scanning %d changed lines...\n\n", len(changesToCheck))
213
263
 
214
264
  // Get API key once upfront before checking
215
265
  apiKey, err := config.GetAPIKey()
@@ -220,16 +270,17 @@ func Run() error {
220
270
  // Check changes against policies (both regex and LLM-based)
221
271
  ctx := context.Background()
222
272
  checkStart := time.Now()
223
- result := checker.CheckChanges(ctx, diffResult.Changes, policies, apiKey)
273
+ result := checker.CheckChanges(ctx, changesToCheck, policies, apiKey)
224
274
  checkDuration := time.Since(checkStart)
225
275
 
226
276
  // Track policy check results (similar to policy.execute.result in github-app)
227
277
  metrics.Track("cli.policy.check.result", map[string]interface{}{
228
278
  "policies_checked": len(policies),
229
- "changes_checked": len(diffResult.Changes),
279
+ "changes_checked": len(changesToCheck),
230
280
  "violations_found": len(result.Violations),
231
281
  "has_violations": len(result.Violations) > 0,
232
282
  "duration_seconds": checkDuration.Seconds(),
283
+ "used_snapshot": useSnapshot,
233
284
  })
234
285
 
235
286
  // Handle results based on whether violations were found
@@ -0,0 +1,25 @@
1
+ package commands
2
+
3
+ import (
4
+ "fmt"
5
+
6
+ "github.com/tanagram/cli/snapshot"
7
+ "github.com/tanagram/cli/storage"
8
+ )
9
+
10
+ // Snapshot creates a snapshot of the current working directory state
11
+ func Snapshot() error {
12
+ // Find git root
13
+ gitRoot, err := storage.FindGitRoot()
14
+ if err != nil {
15
+ return err
16
+ }
17
+
18
+ // Create snapshot
19
+ if err := snapshot.Create(gitRoot); err != nil {
20
+ return fmt.Errorf("failed to create snapshot: %w", err)
21
+ }
22
+
23
+ // Silent success - no output for hooks
24
+ return nil
25
+ }
package/install.js CHANGED
@@ -4,6 +4,7 @@ const { execSync } = require('child_process');
4
4
  const fs = require('fs');
5
5
  const path = require('path');
6
6
  const os = require('os');
7
+ const pkg = require('./package.json');
7
8
 
8
9
  const GO_VERSION = '1.21.0';
9
10
  const GO_CACHE_DIR = path.join(os.homedir(), '.tanagram', `go-${GO_VERSION}`);
@@ -80,7 +81,8 @@ function buildBinary(goCommand) {
80
81
  console.log('🔧 Building Tanagram CLI...');
81
82
 
82
83
  try {
83
- execSync(`"${goCommand}" build -o "${binaryPath}" .`, {
84
+ const ldflags = `-X 'main.Version=${pkg.version}'`;
85
+ execSync(`"${goCommand}" build -ldflags "${ldflags}" -o "${binaryPath}" .`, {
84
86
  cwd: __dirname,
85
87
  stdio: 'inherit',
86
88
  env: {
package/main.go CHANGED
@@ -9,7 +9,11 @@ import (
9
9
  "github.com/tanagram/cli/tui"
10
10
  )
11
11
 
12
+ // Version is set in install.js via `-X` ldflags
13
+ var Version = "dev"
14
+
12
15
  func main() {
16
+ metrics.SetVersion(Version)
13
17
  metrics.Init()
14
18
 
15
19
  exitCode := 0
@@ -40,6 +44,11 @@ func main() {
40
44
  "command": "run",
41
45
  })
42
46
  err = commands.Run()
47
+ case "snapshot":
48
+ metrics.Track("cli.command.execute", map[string]interface{}{
49
+ "command": "snapshot",
50
+ })
51
+ err = commands.Snapshot()
43
52
  case "sync":
44
53
  metrics.Track("cli.command.execute", map[string]interface{}{
45
54
  "command": "sync",
@@ -109,6 +118,9 @@ func main() {
109
118
  return
110
119
  }
111
120
  return
121
+ case "version", "-v", "--version":
122
+ fmt.Println(Version)
123
+ return
112
124
  case "help", "-h", "--help":
113
125
  printHelp()
114
126
  return
@@ -138,19 +150,23 @@ USAGE:
138
150
 
139
151
  COMMANDS:
140
152
  run Check git changes against policies (default)
153
+ snapshot Create a snapshot of current file state (used by PreToolUse hook)
141
154
  sync Manually sync instruction files to cache
142
155
  list Show all cached policies
143
156
  config claude Setup Claude Code hook automatically
144
157
  config list Show hook installation status
158
+ version Show the CLI version
145
159
  help Show this help message
146
160
 
147
161
  EXAMPLES:
148
162
  tanagram # Check changes (auto-syncs if files changed)
149
163
  tanagram run # Same as above
164
+ tanagram snapshot # Create snapshot before making changes
150
165
  tanagram sync # Manually sync policies
151
166
  tanagram list # View all cached policies
152
- tanagram config claude # Setup Claude Code hook in ~/.claude/settings.json
167
+ tanagram config claude # Setup Claude Code hooks in ~/.claude/settings.json
153
168
  tanagram config list # Show where hooks are installed
169
+ tanagram version # Show the version
154
170
 
155
171
  INSTRUCTION FILES:
156
172
  Tanagram looks for instruction files in your git repository:
@@ -158,6 +174,13 @@ INSTRUCTION FILES:
158
174
  - Cursor rules: .cursor/rules/*.mdc
159
175
 
160
176
  Policies are cached and automatically resynced when files change.
177
+
178
+ HOOK WORKFLOW:
179
+ When configured with 'tanagram config claude', two hooks are installed:
180
+ 1. PreToolUse (Edit|Write): Creates snapshot before Claude makes changes
181
+ 2. PostToolUse (Edit|Write): Checks only Claude's changes against policies
182
+
183
+ This prevents false positives from user-written code!
161
184
  `
162
185
  fmt.Print(help)
163
186
  }
@@ -14,8 +14,14 @@ var (
14
14
  // Injected at build time via -ldflags
15
15
  posthogWriteKey = "phc_sMsUvf0nK50rZdztSlX9rDJqIreLcXj4dyGS0tORQpQ"
16
16
  posthogHost = "https://us.i.posthog.com"
17
+ version = "dev"
17
18
  )
18
19
 
20
+ // SetVersion sets the CLI version
21
+ func SetVersion(v string) {
22
+ version = v
23
+ }
24
+
19
25
  // Init initializes the PostHog client
20
26
  // Similar to PosthogService.py and webui/app/lib/posthog.ts
21
27
  func Init() {
@@ -117,6 +123,5 @@ func getDeploymentEnv() string {
117
123
 
118
124
  // getVersion returns the CLI version
119
125
  func getVersion() string {
120
- // TODO: embed version at build time with -ldflags
121
- return "0.1.14"
126
+ return version
122
127
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@tanagram/cli",
3
- "version": "0.1.33",
3
+ "version": "0.2.1",
4
4
  "description": "Tanagram - Catch sloppy code before it ships",
5
5
  "main": "index.js",
6
6
  "bin": {
@@ -42,6 +42,7 @@
42
42
  "llm/",
43
43
  "metrics/",
44
44
  "parser/",
45
+ "snapshot/",
45
46
  "storage/",
46
47
  "tui/",
47
48
  "main.go",
@@ -0,0 +1,418 @@
1
+ package snapshot
2
+
3
+ import (
4
+ "crypto/md5"
5
+ "encoding/json"
6
+ "fmt"
7
+ "io"
8
+ "os"
9
+ "path/filepath"
10
+ "strings"
11
+ "time"
12
+
13
+ "github.com/tanagram/cli/storage"
14
+ )
15
+
16
+ // FileState represents the state of a single file
17
+ type FileState struct {
18
+ Path string `json:"path"`
19
+ Hash string `json:"hash"`
20
+ Size int64 `json:"size"`
21
+ ModifiedTime time.Time `json:"modified_time"`
22
+ }
23
+
24
+ // Snapshot represents a snapshot of the working directory
25
+ type Snapshot struct {
26
+ Timestamp time.Time `json:"timestamp"`
27
+ Files map[string]*FileState `json:"files"`
28
+ }
29
+
30
+ // SnapshotPath returns the path to the snapshot file
31
+ func SnapshotPath(gitRoot string) string {
32
+ return filepath.Join(gitRoot, ".tanagram", "snapshot.json")
33
+ }
34
+
35
+ // Create creates a new snapshot of all tracked and modified files
36
+ func Create(gitRoot string) error {
37
+ snapshot := &Snapshot{
38
+ Timestamp: time.Now(),
39
+ Files: make(map[string]*FileState),
40
+ }
41
+
42
+ // Walk the git repository
43
+ err := filepath.Walk(gitRoot, func(path string, info os.FileInfo, err error) error {
44
+ if err != nil {
45
+ return err
46
+ }
47
+
48
+ // Skip .git directory and .tanagram directory
49
+ if info.IsDir() {
50
+ if info.Name() == ".git" || info.Name() == ".tanagram" || info.Name() == "node_modules" {
51
+ return filepath.SkipDir
52
+ }
53
+ return nil
54
+ }
55
+
56
+ // Get relative path
57
+ relPath, err := filepath.Rel(gitRoot, path)
58
+ if err != nil {
59
+ return err
60
+ }
61
+
62
+ // Calculate file hash
63
+ hash, err := hashFile(path)
64
+ if err != nil {
65
+ // Skip files we can't read (permissions, etc)
66
+ return nil
67
+ }
68
+
69
+ snapshot.Files[relPath] = &FileState{
70
+ Path: relPath,
71
+ Hash: hash,
72
+ Size: info.Size(),
73
+ ModifiedTime: info.ModTime(),
74
+ }
75
+
76
+ return nil
77
+ })
78
+
79
+ if err != nil {
80
+ return fmt.Errorf("failed to walk directory: %w", err)
81
+ }
82
+
83
+ // Save snapshot
84
+ return Save(gitRoot, snapshot)
85
+ }
86
+
87
+ // Load loads an existing snapshot
88
+ func Load(gitRoot string) (*Snapshot, error) {
89
+ path := SnapshotPath(gitRoot)
90
+
91
+ data, err := os.ReadFile(path)
92
+ if err != nil {
93
+ if os.IsNotExist(err) {
94
+ return nil, nil // No snapshot exists
95
+ }
96
+ return nil, fmt.Errorf("failed to read snapshot: %w", err)
97
+ }
98
+
99
+ var snapshot Snapshot
100
+ if err := json.Unmarshal(data, &snapshot); err != nil {
101
+ return nil, fmt.Errorf("failed to parse snapshot: %w", err)
102
+ }
103
+
104
+ return &snapshot, nil
105
+ }
106
+
107
+ // Save saves a snapshot to disk
108
+ func Save(gitRoot string, snapshot *Snapshot) error {
109
+ path := SnapshotPath(gitRoot)
110
+
111
+ // Ensure .tanagram directory exists
112
+ tanagramDir := filepath.Join(gitRoot, ".tanagram")
113
+ if err := os.MkdirAll(tanagramDir, 0755); err != nil {
114
+ return fmt.Errorf("failed to create .tanagram directory: %w", err)
115
+ }
116
+
117
+ // Marshal snapshot
118
+ data, err := json.MarshalIndent(snapshot, "", " ")
119
+ if err != nil {
120
+ return fmt.Errorf("failed to marshal snapshot: %w", err)
121
+ }
122
+
123
+ // Write to file
124
+ if err := os.WriteFile(path, data, 0644); err != nil {
125
+ return fmt.Errorf("failed to write snapshot: %w", err)
126
+ }
127
+
128
+ return nil
129
+ }
130
+
131
+ // Delete removes the snapshot file
132
+ func Delete(gitRoot string) error {
133
+ path := SnapshotPath(gitRoot)
134
+ if err := os.Remove(path); err != nil && !os.IsNotExist(err) {
135
+ return fmt.Errorf("failed to delete snapshot: %w", err)
136
+ }
137
+ return nil
138
+ }
139
+
140
+ // CompareResult represents the result of comparing two snapshots
141
+ type CompareResult struct {
142
+ ModifiedFiles []string
143
+ DeletedFiles []string
144
+ NewFiles []string
145
+ }
146
+
147
+ // Compare compares the current working directory to a snapshot
148
+ func Compare(gitRoot string, snapshot *Snapshot) (*CompareResult, error) {
149
+ result := &CompareResult{
150
+ ModifiedFiles: []string{},
151
+ DeletedFiles: []string{},
152
+ NewFiles: []string{},
153
+ }
154
+
155
+ // Get current file states
156
+ currentFiles := make(map[string]*FileState)
157
+ err := filepath.Walk(gitRoot, func(path string, info os.FileInfo, err error) error {
158
+ if err != nil {
159
+ return err
160
+ }
161
+
162
+ // Skip directories
163
+ if info.IsDir() {
164
+ if info.Name() == ".git" || info.Name() == ".tanagram" || info.Name() == "node_modules" {
165
+ return filepath.SkipDir
166
+ }
167
+ return nil
168
+ }
169
+
170
+ // Get relative path
171
+ relPath, err := filepath.Rel(gitRoot, path)
172
+ if err != nil {
173
+ return err
174
+ }
175
+
176
+ // Calculate file hash
177
+ hash, err := hashFile(path)
178
+ if err != nil {
179
+ return nil
180
+ }
181
+
182
+ currentFiles[relPath] = &FileState{
183
+ Path: relPath,
184
+ Hash: hash,
185
+ Size: info.Size(),
186
+ ModifiedTime: info.ModTime(),
187
+ }
188
+
189
+ return nil
190
+ })
191
+
192
+ if err != nil {
193
+ return nil, fmt.Errorf("failed to walk directory: %w", err)
194
+ }
195
+
196
+ // Find modified and new files
197
+ for path, currentState := range currentFiles {
198
+ if snapshotState, exists := snapshot.Files[path]; exists {
199
+ // File existed in snapshot - check if modified
200
+ if currentState.Hash != snapshotState.Hash {
201
+ result.ModifiedFiles = append(result.ModifiedFiles, path)
202
+ }
203
+ } else {
204
+ // File is new
205
+ result.NewFiles = append(result.NewFiles, path)
206
+ }
207
+ }
208
+
209
+ // Find deleted files
210
+ for path := range snapshot.Files {
211
+ if _, exists := currentFiles[path]; !exists {
212
+ result.DeletedFiles = append(result.DeletedFiles, path)
213
+ }
214
+ }
215
+
216
+ return result, nil
217
+ }
218
+
219
+ // GetChangedFileContents reads the contents of files that changed
220
+ func GetChangedFileContents(gitRoot string, compareResult *CompareResult) (map[string]string, error) {
221
+ contents := make(map[string]string)
222
+
223
+ // Read contents of modified and new files
224
+ allChangedFiles := append(compareResult.ModifiedFiles, compareResult.NewFiles...)
225
+
226
+ for _, relPath := range allChangedFiles {
227
+ fullPath := filepath.Join(gitRoot, relPath)
228
+
229
+ // Skip binary files and very large files
230
+ info, err := os.Stat(fullPath)
231
+ if err != nil {
232
+ continue
233
+ }
234
+
235
+ // Skip files larger than 1MB
236
+ if info.Size() > 1024*1024 {
237
+ continue
238
+ }
239
+
240
+ data, err := os.ReadFile(fullPath)
241
+ if err != nil {
242
+ continue
243
+ }
244
+
245
+ // Check if file is likely text
246
+ if isText(data) {
247
+ contents[relPath] = string(data)
248
+ }
249
+ }
250
+
251
+ return contents, nil
252
+ }
253
+
254
+ // hashFile calculates the MD5 hash of a file
255
+ func hashFile(path string) (string, error) {
256
+ file, err := os.Open(path)
257
+ if err != nil {
258
+ return "", err
259
+ }
260
+ defer file.Close()
261
+
262
+ hash := md5.New()
263
+ if _, err := io.Copy(hash, file); err != nil {
264
+ return "", err
265
+ }
266
+
267
+ return fmt.Sprintf("%x", hash.Sum(nil)), nil
268
+ }
269
+
270
+ // isText checks if data is likely text (not binary)
271
+ func isText(data []byte) bool {
272
+ if len(data) == 0 {
273
+ return true
274
+ }
275
+
276
+ // Check first 512 bytes for null bytes
277
+ checkLen := len(data)
278
+ if checkLen > 512 {
279
+ checkLen = 512
280
+ }
281
+
282
+ for i := 0; i < checkLen; i++ {
283
+ if data[i] == 0 {
284
+ return false
285
+ }
286
+ }
287
+
288
+ return true
289
+ }
290
+
291
+ // Exists checks if a snapshot exists
292
+ func Exists(gitRoot string) bool {
293
+ _, err := os.Stat(SnapshotPath(gitRoot))
294
+ return err == nil
295
+ }
296
+
297
+ // Note: We use a simple approach here - for modified files, we include ALL lines
298
+ // A more sophisticated approach would do line-by-line diffing to find exact changes
299
+ // But this works well enough for our use case since we only check files that changed
300
+
301
+ // GetChangedLinesForChecker extracts line-level changes and returns them in a format
302
+ // compatible with what the checker expects (same as git diff output)
303
+ type ChangedLine struct {
304
+ File string
305
+ LineNumber int
306
+ Content string
307
+ ChangeType string // "+" for added/modified
308
+ }
309
+
310
+ func GetChangedLinesForChecker(gitRoot string, snapshot *Snapshot, compareResult *CompareResult) ([]ChangedLine, error) {
311
+ var changes []ChangedLine
312
+
313
+ // For each modified file, include all non-empty lines
314
+ // (we can't easily determine which specific lines changed without doing a full diff)
315
+ for _, relPath := range compareResult.ModifiedFiles {
316
+ fullPath := filepath.Join(gitRoot, relPath)
317
+
318
+ // Read new content
319
+ newData, err := os.ReadFile(fullPath)
320
+ if err != nil {
321
+ continue
322
+ }
323
+
324
+ // Skip binary files
325
+ if !isText(newData) {
326
+ continue
327
+ }
328
+
329
+ newLines := strings.Split(string(newData), "\n")
330
+
331
+ // Include all non-empty lines from modified files
332
+ for i, line := range newLines {
333
+ if strings.TrimSpace(line) != "" {
334
+ changes = append(changes, ChangedLine{
335
+ File: relPath,
336
+ LineNumber: i + 1,
337
+ Content: line,
338
+ ChangeType: "+",
339
+ })
340
+ }
341
+ }
342
+ }
343
+
344
+ // For new files, all lines are "added"
345
+ for _, relPath := range compareResult.NewFiles {
346
+ fullPath := filepath.Join(gitRoot, relPath)
347
+
348
+ data, err := os.ReadFile(fullPath)
349
+ if err != nil {
350
+ continue
351
+ }
352
+
353
+ if !isText(data) {
354
+ continue
355
+ }
356
+
357
+ lines := strings.Split(string(data), "\n")
358
+ for i, line := range lines {
359
+ if strings.TrimSpace(line) != "" {
360
+ changes = append(changes, ChangedLine{
361
+ File: relPath,
362
+ LineNumber: i + 1,
363
+ Content: line,
364
+ ChangeType: "+",
365
+ })
366
+ }
367
+ }
368
+ }
369
+
370
+ return changes, nil
371
+ }
372
+
373
+ // init adds .tanagram to .gitignore if not already present
374
+ func EnsureGitIgnore(gitRoot string) error {
375
+ gitignorePath := filepath.Join(gitRoot, ".gitignore")
376
+
377
+ // Read existing .gitignore
378
+ var existingContent string
379
+ data, err := os.ReadFile(gitignorePath)
380
+ if err != nil && !os.IsNotExist(err) {
381
+ return fmt.Errorf("failed to read .gitignore: %w", err)
382
+ }
383
+ if err == nil {
384
+ existingContent = string(data)
385
+ }
386
+
387
+ // Check if .tanagram is already in .gitignore
388
+ if strings.Contains(existingContent, ".tanagram") {
389
+ return nil
390
+ }
391
+
392
+ // Append .tanagram to .gitignore
393
+ f, err := os.OpenFile(gitignorePath, os.O_APPEND|os.O_CREATE|os.O_WRONLY, 0644)
394
+ if err != nil {
395
+ return fmt.Errorf("failed to open .gitignore: %w", err)
396
+ }
397
+ defer f.Close()
398
+
399
+ if existingContent != "" && !strings.HasSuffix(existingContent, "\n") {
400
+ if _, err := f.WriteString("\n"); err != nil {
401
+ return err
402
+ }
403
+ }
404
+
405
+ if _, err := f.WriteString(".tanagram/\n"); err != nil {
406
+ return fmt.Errorf("failed to write to .gitignore: %w", err)
407
+ }
408
+
409
+ return nil
410
+ }
411
+
412
+ func init() {
413
+ // Try to add .tanagram to .gitignore when package is loaded
414
+ gitRoot, err := storage.FindGitRoot()
415
+ if err == nil {
416
+ _ = EnsureGitIgnore(gitRoot)
417
+ }
418
+ }