@tanagram/cli 0.4.19 → 0.4.20
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 +21 -10
- package/commands/config_test.go +10 -9
- package/commands/list.go +14 -1
- package/commands/login.go +57 -1
- package/commands/login_test.go +132 -0
- package/commands/run.go +194 -156
- package/commands/snapshot.go +11 -1
- package/commands/snapshot_test.go +2 -1
- package/commands/sync.go +65 -29
- package/commands/sync_policies.go +14 -1
- package/dist/npm/darwin-arm64/tanagram +0 -0
- package/dist/npm/darwin-x64/tanagram +0 -0
- package/dist/npm/linux-arm64/tanagram +0 -0
- package/dist/npm/linux-x64/tanagram +0 -0
- package/dist/npm/tanagram_0.4.20_darwin_amd64.tar.gz +0 -0
- package/dist/npm/tanagram_0.4.20_darwin_arm64.tar.gz +0 -0
- package/dist/npm/tanagram_0.4.20_linux_amd64.tar.gz +0 -0
- package/dist/npm/tanagram_0.4.20_linux_arm64.tar.gz +0 -0
- package/dist/npm/tanagram_0.4.20_windows_amd64.zip +0 -0
- package/dist/npm/win32-x64/tanagram.exe +0 -0
- package/go.mod +6 -4
- package/go.sum +16 -2
- package/main.go +94 -27
- package/package.json +1 -1
- package/utils/sentry.go +44 -0
- package/dist/npm/tanagram_0.4.19_darwin_amd64.tar.gz +0 -0
- package/dist/npm/tanagram_0.4.19_darwin_arm64.tar.gz +0 -0
- package/dist/npm/tanagram_0.4.19_linux_amd64.tar.gz +0 -0
- package/dist/npm/tanagram_0.4.19_linux_arm64.tar.gz +0 -0
- package/dist/npm/tanagram_0.4.19_windows_amd64.zip +0 -0
package/commands/run.go
CHANGED
|
@@ -9,6 +9,7 @@ import (
|
|
|
9
9
|
"sync"
|
|
10
10
|
"time"
|
|
11
11
|
|
|
12
|
+
"github.com/getsentry/sentry-go"
|
|
12
13
|
"github.com/tanagram/cli/checker"
|
|
13
14
|
"github.com/tanagram/cli/config"
|
|
14
15
|
"github.com/tanagram/cli/extractor"
|
|
@@ -37,16 +38,132 @@ func spinner(stop chan bool, message string) {
|
|
|
37
38
|
}
|
|
38
39
|
}
|
|
39
40
|
|
|
41
|
+
// syncPolicies extracts policies from changed files using the LLM
|
|
42
|
+
func syncPolicies(ctx context.Context, filesToSync []string, gitRoot string, cache *storage.Cache) error {
|
|
43
|
+
syncSpan := sentry.StartSpan(ctx, "commands.auto_sync_policies")
|
|
44
|
+
defer syncSpan.Finish()
|
|
45
|
+
syncSpan.SetData("files_to_sync", len(filesToSync))
|
|
46
|
+
|
|
47
|
+
apiKey, err := config.GetAPIKey()
|
|
48
|
+
if err != nil {
|
|
49
|
+
return err
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
slog.Info("Syncing policies with LLM", "files_to_process", len(filesToSync))
|
|
53
|
+
|
|
54
|
+
syncStart := time.Now()
|
|
55
|
+
|
|
56
|
+
type syncResult struct {
|
|
57
|
+
file string
|
|
58
|
+
relPath string
|
|
59
|
+
policies []parser.Policy
|
|
60
|
+
err error
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
results := make(chan syncResult, len(filesToSync))
|
|
64
|
+
var wg sync.WaitGroup
|
|
65
|
+
|
|
66
|
+
stop := make(chan bool)
|
|
67
|
+
var completed int
|
|
68
|
+
var mu sync.Mutex
|
|
69
|
+
go func() {
|
|
70
|
+
for {
|
|
71
|
+
select {
|
|
72
|
+
case <-stop:
|
|
73
|
+
slog.Info("spinner stopped")
|
|
74
|
+
return
|
|
75
|
+
default:
|
|
76
|
+
mu.Lock()
|
|
77
|
+
c := completed
|
|
78
|
+
mu.Unlock()
|
|
79
|
+
slog.Info("Processing files", "completed", c, "total", len(filesToSync))
|
|
80
|
+
time.Sleep(100 * time.Millisecond)
|
|
81
|
+
}
|
|
82
|
+
}
|
|
83
|
+
}()
|
|
84
|
+
|
|
85
|
+
for _, file := range filesToSync {
|
|
86
|
+
wg.Add(1)
|
|
87
|
+
go func(file string) {
|
|
88
|
+
defer wg.Done()
|
|
89
|
+
relPath, _ := filepath.Rel(gitRoot, file)
|
|
90
|
+
policies, err := extractor.ExtractPoliciesFromFile(ctx, file, apiKey)
|
|
91
|
+
results <- syncResult{file, relPath, policies, err}
|
|
92
|
+
}(file)
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
go func() {
|
|
96
|
+
wg.Wait()
|
|
97
|
+
close(results)
|
|
98
|
+
}()
|
|
99
|
+
|
|
100
|
+
totalPolicies := 0
|
|
101
|
+
for result := range results {
|
|
102
|
+
if result.err != nil {
|
|
103
|
+
stop <- true
|
|
104
|
+
close(stop)
|
|
105
|
+
time.Sleep(50 * time.Millisecond)
|
|
106
|
+
mu.Lock()
|
|
107
|
+
slog.Error("Failed to process file", "file", result.relPath)
|
|
108
|
+
mu.Unlock()
|
|
109
|
+
return fmt.Errorf("failed to extract policies from %s: %w", result.file, result.err)
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
if err := cache.UpdateFile(result.file, result.policies); err != nil {
|
|
113
|
+
stop <- true
|
|
114
|
+
close(stop)
|
|
115
|
+
time.Sleep(50 * time.Millisecond)
|
|
116
|
+
return fmt.Errorf("failed to update cache for %s: %w", result.file, err)
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
totalPolicies += len(result.policies)
|
|
120
|
+
|
|
121
|
+
mu.Lock()
|
|
122
|
+
completed++
|
|
123
|
+
slog.Info("Processed file", "file", result.relPath, "policies", len(result.policies))
|
|
124
|
+
mu.Unlock()
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
stop <- true
|
|
128
|
+
close(stop)
|
|
129
|
+
time.Sleep(50 * time.Millisecond)
|
|
130
|
+
|
|
131
|
+
if err := cache.Save(); err != nil {
|
|
132
|
+
return fmt.Errorf("failed to save cache: %w", err)
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
syncDuration := time.Since(syncStart)
|
|
136
|
+
slog.Info("Sync complete", "policies_synced", totalPolicies, "files_synced", len(filesToSync))
|
|
137
|
+
|
|
138
|
+
metrics.Track("cli.sync.complete", map[string]interface{}{
|
|
139
|
+
"files_synced": len(filesToSync),
|
|
140
|
+
"policies_synced": totalPolicies,
|
|
141
|
+
"duration_seconds": syncDuration.Seconds(),
|
|
142
|
+
})
|
|
143
|
+
|
|
144
|
+
return nil
|
|
145
|
+
}
|
|
146
|
+
|
|
40
147
|
// Run executes the main policy check with auto-sync
|
|
41
|
-
func Run() error {
|
|
148
|
+
func Run(ctx context.Context) error {
|
|
149
|
+
span := sentry.StartSpan(ctx, "command.run")
|
|
150
|
+
defer span.Finish()
|
|
151
|
+
ctx = span.Context()
|
|
152
|
+
|
|
153
|
+
utils.AddBreadcrumb("run", "Starting policy check", sentry.LevelInfo, nil)
|
|
154
|
+
|
|
42
155
|
// Find git root
|
|
156
|
+
findGitRootSpan := sentry.StartSpan(ctx, "storage.find_git_root")
|
|
43
157
|
gitRoot, err := storage.FindGitRoot()
|
|
158
|
+
findGitRootSpan.Finish()
|
|
44
159
|
if err != nil {
|
|
45
160
|
return err
|
|
46
161
|
}
|
|
47
162
|
|
|
48
163
|
// Find all instruction files
|
|
164
|
+
findFilesSpan := sentry.StartSpan(ctx, "commands.find_instruction_files")
|
|
49
165
|
instructionFiles, err := FindInstructionFiles(gitRoot)
|
|
166
|
+
findFilesSpan.Finish()
|
|
50
167
|
if err != nil {
|
|
51
168
|
return err
|
|
52
169
|
}
|
|
@@ -56,7 +173,9 @@ func Run() error {
|
|
|
56
173
|
}
|
|
57
174
|
|
|
58
175
|
// Load cache
|
|
176
|
+
loadCacheSpan := sentry.StartSpan(ctx, "storage.load_cache")
|
|
59
177
|
cache, err := storage.LoadCache(gitRoot)
|
|
178
|
+
loadCacheSpan.Finish()
|
|
60
179
|
if err != nil {
|
|
61
180
|
return fmt.Errorf("failed to load cache: %w", err)
|
|
62
181
|
}
|
|
@@ -75,122 +194,21 @@ func Run() error {
|
|
|
75
194
|
|
|
76
195
|
// Auto-sync only the files that changed
|
|
77
196
|
if len(filesToSync) > 0 {
|
|
78
|
-
|
|
79
|
-
apiKey, err := config.GetAPIKey()
|
|
80
|
-
if err != nil {
|
|
197
|
+
if err := syncPolicies(ctx, filesToSync, gitRoot, cache); err != nil {
|
|
81
198
|
return err
|
|
82
199
|
}
|
|
83
|
-
|
|
84
|
-
slog.Info("Syncing policies with LLM", "files_to_process", len(filesToSync))
|
|
85
|
-
|
|
86
|
-
syncStart := time.Now()
|
|
87
|
-
ctx := context.Background()
|
|
88
|
-
|
|
89
|
-
// Result type for collecting sync results
|
|
90
|
-
type syncResult struct {
|
|
91
|
-
file string
|
|
92
|
-
relPath string
|
|
93
|
-
policies []parser.Policy
|
|
94
|
-
err error
|
|
95
|
-
}
|
|
96
|
-
|
|
97
|
-
// Channel to collect results
|
|
98
|
-
results := make(chan syncResult, len(filesToSync))
|
|
99
|
-
var wg sync.WaitGroup
|
|
100
|
-
|
|
101
|
-
// Start spinner
|
|
102
|
-
stop := make(chan bool)
|
|
103
|
-
var completed int
|
|
104
|
-
var mu sync.Mutex
|
|
105
|
-
go func() {
|
|
106
|
-
for {
|
|
107
|
-
select {
|
|
108
|
-
case <-stop:
|
|
109
|
-
slog.Info("spinner stopped")
|
|
110
|
-
return
|
|
111
|
-
default:
|
|
112
|
-
mu.Lock()
|
|
113
|
-
c := completed
|
|
114
|
-
mu.Unlock()
|
|
115
|
-
slog.Info("Processing files", "completed", c, "total", len(filesToSync))
|
|
116
|
-
time.Sleep(100 * time.Millisecond)
|
|
117
|
-
}
|
|
118
|
-
}
|
|
119
|
-
}()
|
|
120
|
-
|
|
121
|
-
// Launch goroutines only for changed files
|
|
122
|
-
for _, file := range filesToSync {
|
|
123
|
-
wg.Add(1)
|
|
124
|
-
go func(file string) {
|
|
125
|
-
defer wg.Done()
|
|
126
|
-
relPath, _ := filepath.Rel(gitRoot, file)
|
|
127
|
-
policies, err := extractor.ExtractPoliciesFromFile(ctx, file, apiKey)
|
|
128
|
-
results <- syncResult{file, relPath, policies, err}
|
|
129
|
-
}(file)
|
|
130
|
-
}
|
|
131
|
-
|
|
132
|
-
// Close results channel when all goroutines complete
|
|
133
|
-
go func() {
|
|
134
|
-
wg.Wait()
|
|
135
|
-
close(results)
|
|
136
|
-
}()
|
|
137
|
-
|
|
138
|
-
// Collect results
|
|
139
|
-
totalPolicies := 0
|
|
140
|
-
for result := range results {
|
|
141
|
-
if result.err != nil {
|
|
142
|
-
stop <- true
|
|
143
|
-
close(stop)
|
|
144
|
-
time.Sleep(50 * time.Millisecond)
|
|
145
|
-
mu.Lock()
|
|
146
|
-
slog.Error("Failed to process file", "file", result.relPath)
|
|
147
|
-
mu.Unlock()
|
|
148
|
-
return fmt.Errorf("failed to extract policies from %s: %w", result.file, result.err)
|
|
149
|
-
}
|
|
150
|
-
|
|
151
|
-
if err := cache.UpdateFile(result.file, result.policies); err != nil {
|
|
152
|
-
stop <- true
|
|
153
|
-
close(stop)
|
|
154
|
-
time.Sleep(50 * time.Millisecond)
|
|
155
|
-
return fmt.Errorf("failed to update cache for %s: %w", result.file, err)
|
|
156
|
-
}
|
|
157
|
-
|
|
158
|
-
totalPolicies += len(result.policies)
|
|
159
|
-
|
|
160
|
-
// Atomic update of counter and output (prevents race with spinner)
|
|
161
|
-
mu.Lock()
|
|
162
|
-
completed++
|
|
163
|
-
slog.Info("Processed file", "file", result.relPath, "policies", len(result.policies))
|
|
164
|
-
mu.Unlock()
|
|
165
|
-
}
|
|
166
|
-
|
|
167
|
-
// Stop spinner
|
|
168
|
-
stop <- true
|
|
169
|
-
close(stop)
|
|
170
|
-
time.Sleep(50 * time.Millisecond)
|
|
171
|
-
|
|
172
|
-
if err := cache.Save(); err != nil {
|
|
173
|
-
return fmt.Errorf("failed to save cache: %w", err)
|
|
174
|
-
}
|
|
175
|
-
|
|
176
|
-
syncDuration := time.Since(syncStart)
|
|
177
|
-
slog.Info("Sync complete", "policies_synced", totalPolicies, "files_synced", len(filesToSync))
|
|
178
|
-
|
|
179
|
-
// Track sync metrics
|
|
180
|
-
metrics.Track("cli.sync.complete", map[string]interface{}{
|
|
181
|
-
"files_synced": len(filesToSync),
|
|
182
|
-
"policies_synced": totalPolicies,
|
|
183
|
-
"duration_seconds": syncDuration.Seconds(),
|
|
184
|
-
})
|
|
185
200
|
}
|
|
186
201
|
|
|
187
202
|
// Load local policies from cache
|
|
203
|
+
loadPoliciesSpan := sentry.StartSpan(ctx, "cache.get_all_policies")
|
|
188
204
|
localPolicies, err := cache.GetAllPolicies()
|
|
205
|
+
loadPoliciesSpan.Finish()
|
|
189
206
|
if err != nil {
|
|
190
207
|
return fmt.Errorf("failed to load policies from cache: %w", err)
|
|
191
208
|
}
|
|
192
209
|
|
|
193
210
|
// Try to load cloud policies for current repo
|
|
211
|
+
cloudPoliciesSpan := sentry.StartSpan(ctx, "storage.load_cloud_policies")
|
|
194
212
|
cloudPolicies := []parser.Policy{}
|
|
195
213
|
cloudStorage := storage.NewCloudPolicyStorage(gitRoot)
|
|
196
214
|
|
|
@@ -206,6 +224,7 @@ func Run() error {
|
|
|
206
224
|
slog.Info("Loaded cloud policies", "count", len(cloudPolicies), "owner", repoInfo.Owner, "repo", repoInfo.Name)
|
|
207
225
|
}
|
|
208
226
|
}
|
|
227
|
+
cloudPoliciesSpan.Finish()
|
|
209
228
|
// If repo detection failed, silently continue with local-only policies
|
|
210
229
|
|
|
211
230
|
// Merge local and cloud policies (cloud takes precedence)
|
|
@@ -227,55 +246,9 @@ func Run() error {
|
|
|
227
246
|
}
|
|
228
247
|
|
|
229
248
|
// Check if a snapshot exists (from PreToolUse hook)
|
|
230
|
-
|
|
231
|
-
|
|
232
|
-
|
|
233
|
-
if snapshot.Exists(gitRoot) {
|
|
234
|
-
slog.Info("Snapshot detected - checking only Claude's changes")
|
|
235
|
-
|
|
236
|
-
// Load snapshot
|
|
237
|
-
snap, err := snapshot.Load(gitRoot)
|
|
238
|
-
if err != nil {
|
|
239
|
-
return fmt.Errorf("failed to load snapshot: %w", err)
|
|
240
|
-
}
|
|
241
|
-
|
|
242
|
-
// Compare current state to snapshot
|
|
243
|
-
compareResult, err := snapshot.Compare(gitRoot, snap)
|
|
244
|
-
if err != nil {
|
|
245
|
-
return fmt.Errorf("failed to compare to snapshot: %w", err)
|
|
246
|
-
}
|
|
247
|
-
|
|
248
|
-
// Get changed lines
|
|
249
|
-
snapshotChanges, err := snapshot.GetChangedLinesForChecker(gitRoot, snap, compareResult)
|
|
250
|
-
if err != nil {
|
|
251
|
-
return fmt.Errorf("failed to get changed lines from snapshot: %w", err)
|
|
252
|
-
}
|
|
253
|
-
|
|
254
|
-
// Convert snapshot.ChangedLine to git.ChangedLine
|
|
255
|
-
for _, sc := range snapshotChanges {
|
|
256
|
-
changesToCheck = append(changesToCheck, gitpkg.ChangedLine{
|
|
257
|
-
File: sc.File,
|
|
258
|
-
LineNumber: sc.LineNumber,
|
|
259
|
-
Content: sc.Content,
|
|
260
|
-
ChangeType: sc.ChangeType,
|
|
261
|
-
})
|
|
262
|
-
}
|
|
263
|
-
|
|
264
|
-
// Delete snapshot after using it
|
|
265
|
-
if err := snapshot.Delete(gitRoot); err != nil {
|
|
266
|
-
slog.Warn("Failed to delete snapshot", "error", err)
|
|
267
|
-
}
|
|
268
|
-
|
|
269
|
-
useSnapshot = true
|
|
270
|
-
} else {
|
|
271
|
-
// No snapshot - fall back to checking all git changes
|
|
272
|
-
slog.Info("Checking all changes (unstaged + staged)")
|
|
273
|
-
diffResult, err := gitpkg.GetAllChanges()
|
|
274
|
-
if err != nil {
|
|
275
|
-
return fmt.Errorf("error getting git diff: %w", err)
|
|
276
|
-
}
|
|
277
|
-
|
|
278
|
-
changesToCheck = diffResult.Changes
|
|
249
|
+
changesToCheck, useSnapshot, err := getChangesToCheck(ctx, gitRoot)
|
|
250
|
+
if err != nil {
|
|
251
|
+
return err
|
|
279
252
|
}
|
|
280
253
|
|
|
281
254
|
if len(changesToCheck) == 0 {
|
|
@@ -287,6 +260,11 @@ func Run() error {
|
|
|
287
260
|
return nil
|
|
288
261
|
}
|
|
289
262
|
|
|
263
|
+
utils.AddBreadcrumb("run", "Scanning changes", sentry.LevelInfo, map[string]interface{}{
|
|
264
|
+
"changes_count": len(changesToCheck),
|
|
265
|
+
"use_snapshot": useSnapshot,
|
|
266
|
+
})
|
|
267
|
+
|
|
290
268
|
slog.Info("Scanning changed lines", "count", len(changesToCheck))
|
|
291
269
|
|
|
292
270
|
// Get API key once upfront before checking
|
|
@@ -296,10 +274,13 @@ func Run() error {
|
|
|
296
274
|
}
|
|
297
275
|
|
|
298
276
|
// Check changes against policies (both regex and LLM-based)
|
|
299
|
-
|
|
277
|
+
checkSpan := sentry.StartSpan(ctx, "checker.check_changes")
|
|
278
|
+
checkSpan.SetData("changes_count", len(changesToCheck))
|
|
279
|
+
checkSpan.SetData("policies_count", len(policies))
|
|
300
280
|
checkStart := time.Now()
|
|
301
281
|
result := checker.CheckChanges(ctx, changesToCheck, policies, apiKey)
|
|
302
282
|
checkDuration := time.Since(checkStart)
|
|
283
|
+
checkSpan.Finish()
|
|
303
284
|
|
|
304
285
|
// Track policy check results (similar to policy.execute.result in github-app)
|
|
305
286
|
metrics.Track("cli.policy.check.result", map[string]interface{}{
|
|
@@ -311,6 +292,11 @@ func Run() error {
|
|
|
311
292
|
"used_snapshot": useSnapshot,
|
|
312
293
|
})
|
|
313
294
|
|
|
295
|
+
utils.AddBreadcrumb("run", "Policy check complete", sentry.LevelInfo, map[string]interface{}{
|
|
296
|
+
"violations_found": len(result.Violations),
|
|
297
|
+
"duration_seconds": checkDuration.Seconds(),
|
|
298
|
+
})
|
|
299
|
+
|
|
314
300
|
// Handle results based on whether violations were found
|
|
315
301
|
if len(result.Violations) > 0 {
|
|
316
302
|
// Determine parent process to decide output format
|
|
@@ -321,6 +307,10 @@ func Run() error {
|
|
|
321
307
|
claudeInstructions := checker.FormatClaudeInstructions(result)
|
|
322
308
|
fmt.Fprint(os.Stderr, claudeInstructions)
|
|
323
309
|
|
|
310
|
+
// Finish span and flush Sentry before exit (os.Exit bypasses deferred functions)
|
|
311
|
+
span.Finish()
|
|
312
|
+
sentry.Flush(2 * time.Second)
|
|
313
|
+
|
|
324
314
|
// Exit with code 2 to trigger Claude Code hook behavior
|
|
325
315
|
os.Exit(2)
|
|
326
316
|
} else if parentProcess == "cursor" {
|
|
@@ -338,3 +328,51 @@ func Run() error {
|
|
|
338
328
|
fmt.Fprint(os.Stdout, "No policy violations found")
|
|
339
329
|
return nil
|
|
340
330
|
}
|
|
331
|
+
|
|
332
|
+
func getChangesToCheck(ctx context.Context, gitRoot string) ([]gitpkg.ChangedLine, bool, error) {
|
|
333
|
+
span := sentry.StartSpan(ctx, "commands.get_changes_to_check")
|
|
334
|
+
defer span.Finish()
|
|
335
|
+
|
|
336
|
+
if snapshot.Exists(gitRoot) {
|
|
337
|
+
slog.Info("Snapshot detected - checking only Claude's changes")
|
|
338
|
+
|
|
339
|
+
snap, err := snapshot.Load(gitRoot)
|
|
340
|
+
if err != nil {
|
|
341
|
+
return nil, false, fmt.Errorf("failed to load snapshot: %w", err)
|
|
342
|
+
}
|
|
343
|
+
|
|
344
|
+
compareResult, err := snapshot.Compare(gitRoot, snap)
|
|
345
|
+
if err != nil {
|
|
346
|
+
return nil, false, fmt.Errorf("failed to compare to snapshot: %w", err)
|
|
347
|
+
}
|
|
348
|
+
|
|
349
|
+
snapshotChanges, err := snapshot.GetChangedLinesForChecker(gitRoot, snap, compareResult)
|
|
350
|
+
if err != nil {
|
|
351
|
+
return nil, false, fmt.Errorf("failed to get changed lines from snapshot: %w", err)
|
|
352
|
+
}
|
|
353
|
+
|
|
354
|
+
var changes []gitpkg.ChangedLine
|
|
355
|
+
for _, sc := range snapshotChanges {
|
|
356
|
+
changes = append(changes, gitpkg.ChangedLine{
|
|
357
|
+
File: sc.File,
|
|
358
|
+
LineNumber: sc.LineNumber,
|
|
359
|
+
Content: sc.Content,
|
|
360
|
+
ChangeType: sc.ChangeType,
|
|
361
|
+
})
|
|
362
|
+
}
|
|
363
|
+
|
|
364
|
+
if err := snapshot.Delete(gitRoot); err != nil {
|
|
365
|
+
slog.Warn("Failed to delete snapshot", "error", err)
|
|
366
|
+
}
|
|
367
|
+
|
|
368
|
+
return changes, true, nil
|
|
369
|
+
}
|
|
370
|
+
|
|
371
|
+
slog.Info("Checking all changes (unstaged + staged)")
|
|
372
|
+
diffResult, err := gitpkg.GetAllChanges()
|
|
373
|
+
if err != nil {
|
|
374
|
+
return nil, false, fmt.Errorf("error getting git diff: %w", err)
|
|
375
|
+
}
|
|
376
|
+
|
|
377
|
+
return diffResult.Changes, false, nil
|
|
378
|
+
}
|
package/commands/snapshot.go
CHANGED
|
@@ -1,10 +1,12 @@
|
|
|
1
1
|
package commands
|
|
2
2
|
|
|
3
3
|
import (
|
|
4
|
+
"context"
|
|
4
5
|
"encoding/json"
|
|
5
6
|
"fmt"
|
|
6
7
|
"os"
|
|
7
8
|
|
|
9
|
+
"github.com/getsentry/sentry-go"
|
|
8
10
|
"github.com/tanagram/cli/snapshot"
|
|
9
11
|
"github.com/tanagram/cli/storage"
|
|
10
12
|
"github.com/tanagram/cli/utils"
|
|
@@ -18,9 +20,14 @@ var (
|
|
|
18
20
|
)
|
|
19
21
|
|
|
20
22
|
// Snapshot creates a snapshot of the current working directory state
|
|
21
|
-
func Snapshot() error {
|
|
23
|
+
func Snapshot(ctx context.Context) error {
|
|
24
|
+
span := sentry.StartSpan(ctx, "command.snapshot")
|
|
25
|
+
defer span.Finish()
|
|
26
|
+
|
|
22
27
|
// Find git root
|
|
28
|
+
findGitRootSpan := sentry.StartSpan(span.Context(), "storage.find_git_root")
|
|
23
29
|
gitRoot, err := findGitRoot()
|
|
30
|
+
findGitRootSpan.Finish()
|
|
24
31
|
if err != nil {
|
|
25
32
|
return err
|
|
26
33
|
}
|
|
@@ -46,9 +53,12 @@ func Snapshot() error {
|
|
|
46
53
|
}
|
|
47
54
|
|
|
48
55
|
// Create snapshot
|
|
56
|
+
createSnapshotSpan := sentry.StartSpan(span.Context(), "snapshot.create_optimized")
|
|
49
57
|
if err := createOptimized(gitRoot, targetFiles); err != nil {
|
|
58
|
+
createSnapshotSpan.Finish()
|
|
50
59
|
return fmt.Errorf("failed to create snapshot: %w", err)
|
|
51
60
|
}
|
|
61
|
+
createSnapshotSpan.Finish()
|
|
52
62
|
|
|
53
63
|
parentProcess := getParentProcess()
|
|
54
64
|
if parentProcess == "Cursor" || parentProcess == "cursor" {
|
|
@@ -2,6 +2,7 @@ package commands
|
|
|
2
2
|
|
|
3
3
|
import (
|
|
4
4
|
"bytes"
|
|
5
|
+
"context"
|
|
5
6
|
"io"
|
|
6
7
|
"os"
|
|
7
8
|
"testing"
|
|
@@ -65,7 +66,7 @@ func TestSnapshot_Cursor(t *testing.T) {
|
|
|
65
66
|
r, w, _ := os.Pipe()
|
|
66
67
|
os.Stdout = w
|
|
67
68
|
|
|
68
|
-
err := Snapshot()
|
|
69
|
+
err := Snapshot(context.Background())
|
|
69
70
|
|
|
70
71
|
// Restore stdout
|
|
71
72
|
w.Close()
|
package/commands/sync.go
CHANGED
|
@@ -8,15 +8,23 @@ import (
|
|
|
8
8
|
"sync"
|
|
9
9
|
"time"
|
|
10
10
|
|
|
11
|
+
"github.com/getsentry/sentry-go"
|
|
11
12
|
"github.com/tanagram/cli/api"
|
|
12
13
|
"github.com/tanagram/cli/config"
|
|
13
14
|
"github.com/tanagram/cli/extractor"
|
|
14
15
|
"github.com/tanagram/cli/parser"
|
|
15
16
|
"github.com/tanagram/cli/storage"
|
|
17
|
+
"github.com/tanagram/cli/utils"
|
|
16
18
|
)
|
|
17
19
|
|
|
18
20
|
// Sync manually syncs all instruction files to the cache
|
|
19
|
-
func Sync() error {
|
|
21
|
+
func Sync(ctx context.Context) error {
|
|
22
|
+
span := sentry.StartSpan(ctx, "command.sync")
|
|
23
|
+
defer span.Finish()
|
|
24
|
+
ctx = span.Context()
|
|
25
|
+
|
|
26
|
+
utils.AddBreadcrumb("sync", "Starting policy sync", sentry.LevelInfo, nil)
|
|
27
|
+
|
|
20
28
|
// Get API key first before doing any work
|
|
21
29
|
apiKey, err := getAPIKey()
|
|
22
30
|
if err != nil {
|
|
@@ -24,13 +32,21 @@ func Sync() error {
|
|
|
24
32
|
}
|
|
25
33
|
|
|
26
34
|
// Find git root
|
|
35
|
+
findGitRootSpan := sentry.StartSpan(ctx, "storage.find_git_root")
|
|
27
36
|
gitRoot, err := storage.FindGitRoot()
|
|
37
|
+
findGitRootSpan.Finish()
|
|
28
38
|
if err != nil {
|
|
29
39
|
return err
|
|
30
40
|
}
|
|
31
41
|
|
|
42
|
+
utils.AddBreadcrumb("sync", "Found git root", sentry.LevelInfo, map[string]interface{}{
|
|
43
|
+
"git_root": gitRoot,
|
|
44
|
+
})
|
|
45
|
+
|
|
32
46
|
// Find all instruction files
|
|
47
|
+
findFilesSpan := sentry.StartSpan(ctx, "commands.find_instruction_files")
|
|
33
48
|
instructionFiles, err := FindInstructionFiles(gitRoot)
|
|
49
|
+
findFilesSpan.Finish()
|
|
34
50
|
if err != nil {
|
|
35
51
|
return err
|
|
36
52
|
}
|
|
@@ -42,7 +58,9 @@ func Sync() error {
|
|
|
42
58
|
fmt.Printf("Found %d instruction file(s)\n", len(instructionFiles))
|
|
43
59
|
|
|
44
60
|
// Load or create cache
|
|
61
|
+
loadCacheSpan := sentry.StartSpan(ctx, "storage.load_cache")
|
|
45
62
|
cache, err := storage.LoadCache(gitRoot)
|
|
63
|
+
loadCacheSpan.Finish()
|
|
46
64
|
if err != nil {
|
|
47
65
|
return fmt.Errorf("failed to load cache: %w", err)
|
|
48
66
|
}
|
|
@@ -74,9 +92,40 @@ func Sync() error {
|
|
|
74
92
|
}
|
|
75
93
|
|
|
76
94
|
// Parse and sync only changed files using LLM in parallel
|
|
77
|
-
|
|
95
|
+
totalPolicies, err := extractPoliciesParallel(ctx, filesToSync, gitRoot, apiKey, cache)
|
|
96
|
+
if err != nil {
|
|
97
|
+
return err
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
// Save cache
|
|
101
|
+
if err := cache.Save(); err != nil {
|
|
102
|
+
return fmt.Errorf("failed to save cache: %w", err)
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
utils.AddBreadcrumb("sync", "Local sync complete", sentry.LevelInfo, map[string]interface{}{
|
|
106
|
+
"policies_synced": totalPolicies,
|
|
107
|
+
"files_synced": len(filesToSync),
|
|
108
|
+
})
|
|
109
|
+
|
|
110
|
+
fmt.Printf("\n✓ Synced %d policies from %d changed file(s)\n", totalPolicies, len(filesToSync))
|
|
111
|
+
|
|
112
|
+
// Also sync cloud policies if user is authenticated
|
|
113
|
+
if err := syncCloudPolicies(gitRoot); err != nil {
|
|
114
|
+
// Don't fail the whole sync if cloud sync fails - just warn
|
|
115
|
+
fmt.Printf("\nWarning: Could not sync cloud policies: %v\n", err)
|
|
116
|
+
fmt.Println("(Run 'tanagram login' to sync policies from cloud)")
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
return nil
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
// extractPoliciesParallel extracts policies from files in parallel using the LLM
|
|
123
|
+
func extractPoliciesParallel(ctx context.Context, filesToSync []string, gitRoot, apiKey string, cache *storage.Cache) (int, error) {
|
|
124
|
+
span := sentry.StartSpan(ctx, "extractor.extract_policies_parallel")
|
|
125
|
+
defer span.Finish()
|
|
126
|
+
span.SetData("files_to_sync", len(filesToSync))
|
|
78
127
|
|
|
79
|
-
|
|
128
|
+
fmt.Printf("\nSyncing policies with LLM (processing %d changed file(s) in parallel)...\n", len(filesToSync))
|
|
80
129
|
|
|
81
130
|
// Result type for collecting sync results
|
|
82
131
|
type syncResult struct {
|
|
@@ -130,24 +179,27 @@ func Sync() error {
|
|
|
130
179
|
close(results)
|
|
131
180
|
}()
|
|
132
181
|
|
|
182
|
+
// stopSpinner stops the spinner goroutine safely
|
|
183
|
+
stopSpinner := func() {
|
|
184
|
+
stop <- true
|
|
185
|
+
close(stop)
|
|
186
|
+
time.Sleep(50 * time.Millisecond)
|
|
187
|
+
}
|
|
188
|
+
|
|
133
189
|
// Collect results
|
|
134
190
|
totalPolicies := 0
|
|
135
191
|
for result := range results {
|
|
136
192
|
if result.err != nil {
|
|
137
|
-
|
|
138
|
-
close(stop)
|
|
139
|
-
time.Sleep(50 * time.Millisecond)
|
|
193
|
+
stopSpinner()
|
|
140
194
|
mu.Lock()
|
|
141
195
|
fmt.Printf("\r\033[K✗ Failed to process %s\n", result.relPath)
|
|
142
196
|
mu.Unlock()
|
|
143
|
-
return fmt.Errorf("failed to extract policies from %s: %w", result.file, result.err)
|
|
197
|
+
return 0, fmt.Errorf("failed to extract policies from %s: %w", result.file, result.err)
|
|
144
198
|
}
|
|
145
199
|
|
|
146
200
|
if err := cache.UpdateFile(result.file, result.policies); err != nil {
|
|
147
|
-
|
|
148
|
-
|
|
149
|
-
time.Sleep(50 * time.Millisecond)
|
|
150
|
-
return fmt.Errorf("failed to update cache for %s: %w", result.file, err)
|
|
201
|
+
stopSpinner()
|
|
202
|
+
return 0, fmt.Errorf("failed to update cache for %s: %w", result.file, err)
|
|
151
203
|
}
|
|
152
204
|
|
|
153
205
|
totalPolicies += len(result.policies)
|
|
@@ -160,25 +212,9 @@ func Sync() error {
|
|
|
160
212
|
}
|
|
161
213
|
|
|
162
214
|
// Stop spinner
|
|
163
|
-
|
|
164
|
-
close(stop)
|
|
165
|
-
time.Sleep(50 * time.Millisecond)
|
|
215
|
+
stopSpinner()
|
|
166
216
|
|
|
167
|
-
|
|
168
|
-
if err := cache.Save(); err != nil {
|
|
169
|
-
return fmt.Errorf("failed to save cache: %w", err)
|
|
170
|
-
}
|
|
171
|
-
|
|
172
|
-
fmt.Printf("\n✓ Synced %d policies from %d changed file(s)\n", totalPolicies, len(filesToSync))
|
|
173
|
-
|
|
174
|
-
// Also sync cloud policies if user is authenticated
|
|
175
|
-
if err := syncCloudPolicies(gitRoot); err != nil {
|
|
176
|
-
// Don't fail the whole sync if cloud sync fails - just warn
|
|
177
|
-
fmt.Printf("\nWarning: Could not sync cloud policies: %v\n", err)
|
|
178
|
-
fmt.Println("(Run 'tanagram login' to sync policies from cloud)")
|
|
179
|
-
}
|
|
180
|
-
|
|
181
|
-
return nil
|
|
217
|
+
return totalPolicies, nil
|
|
182
218
|
}
|
|
183
219
|
|
|
184
220
|
// FindInstructionFiles searches for instruction files in the git repository
|