@tanagram/cli 0.1.32 → 0.2.0
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.
- package/commands/config.go +147 -68
- package/commands/run.go +61 -10
- package/commands/snapshot.go +25 -0
- package/install.js +1 -8
- package/main.go +50 -17
- package/metrics/metrics.go +8 -2
- package/package.json +2 -1
- package/snapshot/snapshot.go +418 -0
package/commands/config.go
CHANGED
|
@@ -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
|
|
55
|
-
|
|
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
|
-
|
|
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
|
|
81
|
-
fmt.Println("✓ Tanagram
|
|
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
|
-
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
|
|
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
|
-
|
|
97
|
-
|
|
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
|
|
116
|
-
fmt.Println("\nClaude Code will now
|
|
117
|
-
fmt.Println("
|
|
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
|
|
152
|
-
|
|
153
|
-
|
|
154
|
-
|
|
155
|
-
|
|
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:
|
|
162
|
-
|
|
163
|
-
|
|
164
|
-
|
|
165
|
-
|
|
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
|
-
|
|
196
|
-
|
|
197
|
-
|
|
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
|
-
|
|
201
|
-
|
|
202
|
-
|
|
203
|
-
|
|
204
|
-
continue
|
|
205
|
-
}
|
|
247
|
+
matcher, ok := hookMap["matcher"].(string)
|
|
248
|
+
if !ok || matcher != "Edit|Write" {
|
|
249
|
+
continue
|
|
250
|
+
}
|
|
206
251
|
|
|
207
|
-
|
|
208
|
-
|
|
209
|
-
|
|
210
|
-
|
|
211
|
-
}
|
|
252
|
+
innerHooks, ok := hookMap["hooks"].([]interface{})
|
|
253
|
+
if !ok {
|
|
254
|
+
continue
|
|
255
|
+
}
|
|
212
256
|
|
|
213
|
-
|
|
214
|
-
|
|
215
|
-
|
|
216
|
-
|
|
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
|
-
|
|
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
|
-
|
|
226
|
-
|
|
289
|
+
for _, innerHook := range innerHooks {
|
|
290
|
+
innerHookMap, ok := innerHook.(map[string]interface{})
|
|
291
|
+
if !ok {
|
|
292
|
+
continue
|
|
293
|
+
}
|
|
227
294
|
|
|
228
|
-
|
|
229
|
-
|
|
230
|
-
status.Command = cmd
|
|
295
|
+
cmd, cmdOk := innerHookMap["command"].(string)
|
|
296
|
+
hookType, typeOk := innerHookMap["type"].(string)
|
|
231
297
|
|
|
232
|
-
|
|
233
|
-
|
|
234
|
-
status.
|
|
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.
|
|
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("
|
|
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
|
|
268
|
-
|
|
269
|
-
|
|
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
|
-
//
|
|
201
|
-
|
|
202
|
-
|
|
203
|
-
|
|
204
|
-
|
|
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(
|
|
208
|
-
|
|
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(
|
|
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,
|
|
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(
|
|
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
|
@@ -79,15 +79,8 @@ function buildBinary(goCommand) {
|
|
|
79
79
|
|
|
80
80
|
console.log('🔧 Building Tanagram CLI...');
|
|
81
81
|
|
|
82
|
-
// Build ldflags to inject PostHog key at build time
|
|
83
|
-
let ldflags = '';
|
|
84
|
-
if (process.env.POSTHOG_WRITE_KEY) {
|
|
85
|
-
ldflags = `-ldflags="-X 'github.com/tanagram/cli/metrics.posthogWriteKey=${process.env.POSTHOG_WRITE_KEY}'"`;
|
|
86
|
-
console.log(' 📊 Injecting PostHog metrics key...');
|
|
87
|
-
}
|
|
88
|
-
|
|
89
82
|
try {
|
|
90
|
-
execSync(`"${goCommand}" build
|
|
83
|
+
execSync(`"${goCommand}" build -o "${binaryPath}" .`, {
|
|
91
84
|
cwd: __dirname,
|
|
92
85
|
stdio: 'inherit',
|
|
93
86
|
env: {
|
package/main.go
CHANGED
|
@@ -10,9 +10,18 @@ import (
|
|
|
10
10
|
)
|
|
11
11
|
|
|
12
12
|
func main() {
|
|
13
|
-
// Initialize metrics (similar to PosthogService initialization)
|
|
14
13
|
metrics.Init()
|
|
15
|
-
|
|
14
|
+
|
|
15
|
+
exitCode := 0
|
|
16
|
+
defer func() {
|
|
17
|
+
metrics.Track("cli.exit", map[string]interface{}{
|
|
18
|
+
"exit_code": exitCode,
|
|
19
|
+
})
|
|
20
|
+
metrics.Close()
|
|
21
|
+
// os.Exit immediately exits without calling other `defer`s, so we need to group these two statements
|
|
22
|
+
// and call them in the right order.
|
|
23
|
+
os.Exit(exitCode)
|
|
24
|
+
}()
|
|
16
25
|
|
|
17
26
|
// Get subcommand (default to "run" if none provided)
|
|
18
27
|
subcommand := "run"
|
|
@@ -20,20 +29,29 @@ func main() {
|
|
|
20
29
|
subcommand = os.Args[1]
|
|
21
30
|
}
|
|
22
31
|
|
|
32
|
+
metrics.Track("cli.start", map[string]interface{}{
|
|
33
|
+
"subcommand": subcommand,
|
|
34
|
+
})
|
|
35
|
+
|
|
23
36
|
var err error
|
|
24
37
|
switch subcommand {
|
|
25
38
|
case "run":
|
|
26
|
-
metrics.Track("command.execute", map[string]interface{}{
|
|
39
|
+
metrics.Track("cli.command.execute", map[string]interface{}{
|
|
27
40
|
"command": "run",
|
|
28
41
|
})
|
|
29
42
|
err = commands.Run()
|
|
43
|
+
case "snapshot":
|
|
44
|
+
metrics.Track("cli.command.execute", map[string]interface{}{
|
|
45
|
+
"command": "snapshot",
|
|
46
|
+
})
|
|
47
|
+
err = commands.Snapshot()
|
|
30
48
|
case "sync":
|
|
31
|
-
metrics.Track("command.execute", map[string]interface{}{
|
|
49
|
+
metrics.Track("cli.command.execute", map[string]interface{}{
|
|
32
50
|
"command": "sync",
|
|
33
51
|
})
|
|
34
52
|
err = commands.Sync()
|
|
35
53
|
case "list":
|
|
36
|
-
metrics.Track("command.execute", map[string]interface{}{
|
|
54
|
+
metrics.Track("cli.command.execute", map[string]interface{}{
|
|
37
55
|
"command": "list",
|
|
38
56
|
})
|
|
39
57
|
err = commands.List()
|
|
@@ -44,32 +62,35 @@ func main() {
|
|
|
44
62
|
fmt.Fprintf(os.Stderr, "\nAvailable subcommands:\n")
|
|
45
63
|
fmt.Fprintf(os.Stderr, " claude Setup Claude Code hook\n")
|
|
46
64
|
fmt.Fprintf(os.Stderr, " list Show hook installation status\n")
|
|
47
|
-
|
|
65
|
+
exitCode = 1
|
|
66
|
+
return
|
|
48
67
|
}
|
|
49
68
|
subCmd := os.Args[2]
|
|
50
69
|
switch subCmd {
|
|
51
70
|
case "claude":
|
|
52
|
-
metrics.Track("command.execute", map[string]interface{}{
|
|
71
|
+
metrics.Track("cli.command.execute", map[string]interface{}{
|
|
53
72
|
"command": "config.claude",
|
|
54
73
|
})
|
|
55
74
|
err = commands.ConfigClaude()
|
|
56
75
|
case "list":
|
|
57
|
-
metrics.Track("command.execute", map[string]interface{}{
|
|
76
|
+
metrics.Track("cli.command.execute", map[string]interface{}{
|
|
58
77
|
"command": "config.list",
|
|
59
78
|
})
|
|
60
79
|
err = commands.ConfigList()
|
|
61
80
|
default:
|
|
62
81
|
fmt.Fprintf(os.Stderr, "Unknown config subcommand: %s\n", subCmd)
|
|
63
|
-
|
|
82
|
+
exitCode = 1
|
|
83
|
+
return
|
|
64
84
|
}
|
|
65
85
|
case "welcome":
|
|
66
|
-
metrics.Track("command.execute", map[string]interface{}{
|
|
86
|
+
metrics.Track("cli.command.execute", map[string]interface{}{
|
|
67
87
|
"command": "welcome",
|
|
68
88
|
})
|
|
69
89
|
choice, err := tui.RunWelcomeScreen()
|
|
70
90
|
if err != nil {
|
|
71
91
|
fmt.Fprintf(os.Stderr, "Error: %v\n", err)
|
|
72
|
-
|
|
92
|
+
exitCode = 1
|
|
93
|
+
return
|
|
73
94
|
}
|
|
74
95
|
switch choice {
|
|
75
96
|
case tui.ChoiceImportPolicies:
|
|
@@ -83,13 +104,14 @@ func main() {
|
|
|
83
104
|
}
|
|
84
105
|
return
|
|
85
106
|
case "puzzle":
|
|
86
|
-
metrics.Track("command.execute", map[string]interface{}{
|
|
107
|
+
metrics.Track("cli.command.execute", map[string]interface{}{
|
|
87
108
|
"command": "puzzle",
|
|
88
109
|
})
|
|
89
110
|
err := tui.RunPuzzleEditor()
|
|
90
111
|
if err != nil {
|
|
91
112
|
fmt.Fprintf(os.Stderr, "Error: %v\n", err)
|
|
92
|
-
|
|
113
|
+
exitCode = 1
|
|
114
|
+
return
|
|
93
115
|
}
|
|
94
116
|
return
|
|
95
117
|
case "help", "-h", "--help":
|
|
@@ -98,16 +120,18 @@ func main() {
|
|
|
98
120
|
default:
|
|
99
121
|
fmt.Fprintf(os.Stderr, "Unknown command: %s\n\n", subcommand)
|
|
100
122
|
printHelp()
|
|
101
|
-
|
|
123
|
+
exitCode = 1
|
|
124
|
+
return
|
|
102
125
|
}
|
|
103
126
|
|
|
104
127
|
if err != nil {
|
|
105
|
-
metrics.Track("command.error", map[string]interface{}{
|
|
128
|
+
metrics.Track("cli.command.error", map[string]interface{}{
|
|
106
129
|
"command": subcommand,
|
|
107
130
|
"error": err.Error(),
|
|
108
131
|
})
|
|
109
132
|
fmt.Fprintf(os.Stderr, "Error: %v\n", err)
|
|
110
|
-
|
|
133
|
+
exitCode = 1
|
|
134
|
+
return
|
|
111
135
|
}
|
|
112
136
|
}
|
|
113
137
|
|
|
@@ -119,6 +143,7 @@ USAGE:
|
|
|
119
143
|
|
|
120
144
|
COMMANDS:
|
|
121
145
|
run Check git changes against policies (default)
|
|
146
|
+
snapshot Create a snapshot of current file state (used by PreToolUse hook)
|
|
122
147
|
sync Manually sync instruction files to cache
|
|
123
148
|
list Show all cached policies
|
|
124
149
|
config claude Setup Claude Code hook automatically
|
|
@@ -128,9 +153,10 @@ COMMANDS:
|
|
|
128
153
|
EXAMPLES:
|
|
129
154
|
tanagram # Check changes (auto-syncs if files changed)
|
|
130
155
|
tanagram run # Same as above
|
|
156
|
+
tanagram snapshot # Create snapshot before making changes
|
|
131
157
|
tanagram sync # Manually sync policies
|
|
132
158
|
tanagram list # View all cached policies
|
|
133
|
-
tanagram config claude # Setup Claude Code
|
|
159
|
+
tanagram config claude # Setup Claude Code hooks in ~/.claude/settings.json
|
|
134
160
|
tanagram config list # Show where hooks are installed
|
|
135
161
|
|
|
136
162
|
INSTRUCTION FILES:
|
|
@@ -139,6 +165,13 @@ INSTRUCTION FILES:
|
|
|
139
165
|
- Cursor rules: .cursor/rules/*.mdc
|
|
140
166
|
|
|
141
167
|
Policies are cached and automatically resynced when files change.
|
|
168
|
+
|
|
169
|
+
HOOK WORKFLOW:
|
|
170
|
+
When configured with 'tanagram config claude', two hooks are installed:
|
|
171
|
+
1. PreToolUse (Edit|Write): Creates snapshot before Claude makes changes
|
|
172
|
+
2. PostToolUse (Edit|Write): Checks only Claude's changes against policies
|
|
173
|
+
|
|
174
|
+
This prevents false positives from user-written code!
|
|
142
175
|
`
|
|
143
176
|
fmt.Print(help)
|
|
144
177
|
}
|
package/metrics/metrics.go
CHANGED
|
@@ -12,7 +12,7 @@ var (
|
|
|
12
12
|
client posthog.Client
|
|
13
13
|
|
|
14
14
|
// Injected at build time via -ldflags
|
|
15
|
-
posthogWriteKey = ""
|
|
15
|
+
posthogWriteKey = "phc_sMsUvf0nK50rZdztSlX9rDJqIreLcXj4dyGS0tORQpQ"
|
|
16
16
|
posthogHost = "https://us.i.posthog.com"
|
|
17
17
|
)
|
|
18
18
|
|
|
@@ -76,10 +76,16 @@ func Track(event string, properties map[string]interface{}) {
|
|
|
76
76
|
properties["arch"] = runtime.GOARCH
|
|
77
77
|
properties["cli_version"] = getVersion()
|
|
78
78
|
|
|
79
|
+
// Build properties - include all custom properties from the caller
|
|
80
|
+
props := posthog.NewProperties()
|
|
81
|
+
for key, value := range properties {
|
|
82
|
+
props.Set(key, value)
|
|
83
|
+
}
|
|
84
|
+
|
|
79
85
|
err := client.Enqueue(posthog.Capture{
|
|
80
86
|
DistinctId: getDistinctId(),
|
|
81
87
|
Event: event,
|
|
82
|
-
Properties:
|
|
88
|
+
Properties: props,
|
|
83
89
|
Timestamp: time.Now(),
|
|
84
90
|
})
|
|
85
91
|
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@tanagram/cli",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.2.0",
|
|
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
|
+
}
|