@tanagram/cli 0.4.9 → 0.4.11

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.
@@ -1,7 +1,9 @@
1
1
  package commands
2
2
 
3
3
  import (
4
+ "encoding/json"
4
5
  "fmt"
6
+ "os"
5
7
 
6
8
  "github.com/tanagram/cli/snapshot"
7
9
  "github.com/tanagram/cli/storage"
@@ -11,7 +13,7 @@ import (
11
13
  // Extracted variables so that tests can override these
12
14
  var (
13
15
  findGitRoot = storage.FindGitRoot
14
- createSnapshot = snapshot.Create
16
+ createOptimized = snapshot.CreateOptimized
15
17
  getParentProcess = utils.GetParentProcess
16
18
  )
17
19
 
@@ -23,8 +25,28 @@ func Snapshot() error {
23
25
  return err
24
26
  }
25
27
 
28
+ var targetFiles []string
29
+
30
+ // Check if there is input on stdin (e.g. from Claude Code hook)
31
+ stat, err := os.Stdin.Stat()
32
+ if err == nil && (stat.Mode()&os.ModeCharDevice) == 0 && stat.Size() > 0 {
33
+ var input struct {
34
+ ToolInput struct {
35
+ FilePath string `json:"file_path"`
36
+ } `json:"tool_input"`
37
+ }
38
+
39
+ decoder := json.NewDecoder(os.Stdin)
40
+ // Ignore errors decoding, just proceed with empty target if fails
41
+ if err := decoder.Decode(&input); err == nil {
42
+ if input.ToolInput.FilePath != "" {
43
+ targetFiles = []string{input.ToolInput.FilePath}
44
+ }
45
+ }
46
+ }
47
+
26
48
  // Create snapshot
27
- if err := createSnapshot(gitRoot); err != nil {
49
+ if err := createOptimized(gitRoot, targetFiles); err != nil {
28
50
  return fmt.Errorf("failed to create snapshot: %w", err)
29
51
  }
30
52
 
package/git/diff.go CHANGED
@@ -66,6 +66,40 @@ func GetAllChanges() (*DiffResult, error) {
66
66
  return result, nil
67
67
  }
68
68
 
69
+ // DiffFiles compares two files using git diff --no-index
70
+ func DiffFiles(oldFile, newFile, displayPath string) (*DiffResult, error) {
71
+ cmd := exec.Command("git", "diff", "--no-index", "--unified=0", oldFile, newFile)
72
+ output, err := cmd.Output()
73
+
74
+ // git diff --no-index returns exit code 1 if there are differences
75
+ // It returns 0 if identical
76
+ if err != nil {
77
+ if exitError, ok := err.(*exec.ExitError); ok {
78
+ if exitError.ExitCode() != 1 {
79
+ // Anything other than 0 or 1 is an actual error
80
+ return nil, fmt.Errorf("failed to run git diff: %w", err)
81
+ }
82
+ // Exit code 1 means differences found, proceed to parse
83
+ } else {
84
+ return nil, fmt.Errorf("failed to run git diff: %w", err)
85
+ }
86
+ }
87
+
88
+ // Parse the diff output
89
+ result, err := parseDiff(string(output))
90
+ if err != nil {
91
+ return nil, err
92
+ }
93
+
94
+ // Fix up the file paths in the result to match the displayPath
95
+ // git diff --no-index outputs temp file paths like "b/tmp/..."
96
+ for i := range result.Changes {
97
+ result.Changes[i].File = displayPath
98
+ }
99
+
100
+ return result, nil
101
+ }
102
+
69
103
  // parseDiff parses unified diff format and extracts changed lines
70
104
  func parseDiff(diffText string) (*DiffResult, error) {
71
105
  result := &DiffResult{
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@tanagram/cli",
3
- "version": "0.4.9",
3
+ "version": "0.4.11",
4
4
  "description": "Tanagram - Catch sloppy code before it ships",
5
5
  "main": "index.js",
6
6
  "bin": {
@@ -1,15 +1,18 @@
1
1
  package snapshot
2
2
 
3
3
  import (
4
+ "bytes"
4
5
  "crypto/md5"
5
6
  "encoding/json"
6
7
  "fmt"
7
8
  "io"
8
9
  "os"
10
+ "os/exec"
9
11
  "path/filepath"
10
12
  "strings"
11
13
  "time"
12
14
 
15
+ "github.com/tanagram/cli/git"
13
16
  "github.com/tanagram/cli/storage"
14
17
  )
15
18
 
@@ -19,12 +22,14 @@ type FileState struct {
19
22
  Hash string `json:"hash"`
20
23
  Size int64 `json:"size"`
21
24
  ModifiedTime time.Time `json:"modified_time"`
25
+ Content string `json:"content,omitempty"`
22
26
  }
23
27
 
24
28
  // Snapshot represents a snapshot of the working directory
25
29
  type Snapshot struct {
26
- Timestamp time.Time `json:"timestamp"`
27
- Files map[string]*FileState `json:"files"`
30
+ Timestamp time.Time `json:"timestamp"`
31
+ HeadCommit string `json:"head_commit,omitempty"`
32
+ Files map[string]*FileState `json:"files"`
28
33
  }
29
34
 
30
35
  // SnapshotPath returns the path to the snapshot file
@@ -32,7 +37,36 @@ func SnapshotPath(gitRoot string) string {
32
37
  return filepath.Join(gitRoot, ".tanagram", "snapshot.json")
33
38
  }
34
39
 
40
+ // runGit executes a git command in the given directory
41
+ func runGit(dir string, args ...string) (string, error) {
42
+ cmd := exec.Command("git", args...)
43
+ cmd.Dir = dir
44
+ out, err := cmd.Output()
45
+ if err != nil {
46
+ return "", err
47
+ }
48
+ return string(bytes.TrimSpace(out)), nil
49
+ }
50
+
51
+ // getHashAtCommit returns the MD5 hash of a file at a specific commit
52
+ func getHashAtCommit(gitRoot, commit, path string) (string, error) {
53
+ // git show commit:path
54
+ cmd := exec.Command("git", "show", fmt.Sprintf("%s:%s", commit, path))
55
+ cmd.Dir = gitRoot
56
+
57
+ // We need to hash the output
58
+ h := md5.New()
59
+ cmd.Stdout = h
60
+
61
+ if err := cmd.Run(); err != nil {
62
+ return "", err
63
+ }
64
+
65
+ return fmt.Sprintf("%x", h.Sum(nil)), nil
66
+ }
67
+
35
68
  // Create creates a new snapshot of all tracked and modified files
69
+ // This is kept for backward compatibility or fallback
36
70
  func Create(gitRoot string) error {
37
71
  snapshot := &Snapshot{
38
72
  Timestamp: time.Now(),
@@ -84,6 +118,87 @@ func Create(gitRoot string) error {
84
118
  return Save(gitRoot, snapshot)
85
119
  }
86
120
 
121
+ // CreateOptimized creates a snapshot using git status/target files
122
+ func CreateOptimized(gitRoot string, targetFiles []string) error {
123
+ // Get current HEAD
124
+ headCommit, err := runGit(gitRoot, "rev-parse", "HEAD")
125
+ if err != nil {
126
+ // Fallback to full create if no git/head
127
+ return Create(gitRoot)
128
+ }
129
+
130
+ snapshot := &Snapshot{
131
+ Timestamp: time.Now(),
132
+ HeadCommit: headCommit,
133
+ Files: make(map[string]*FileState),
134
+ }
135
+
136
+ // Use a map to deduplicate files
137
+ filesToSnapshot := make(map[string]bool)
138
+
139
+ // 1. Add target files (if provided)
140
+ for _, f := range targetFiles {
141
+ filesToSnapshot[f] = true
142
+ }
143
+
144
+ // 2. Add all currently dirty files (modified/staged/untracked)
145
+ // This ensures that if the user has uncommitted changes, we record them
146
+ // so that we don't falsely attribute them to the agent later.
147
+ out, err := runGit(gitRoot, "status", "--porcelain")
148
+ if err == nil {
149
+ lines := strings.Split(out, "\n")
150
+ for _, line := range lines {
151
+ if len(line) < 4 {
152
+ continue
153
+ }
154
+ // Format: XY PATH or XY PATH -> NEWPATH
155
+ content := line[3:]
156
+ if idx := strings.Index(content, " -> "); idx != -1 {
157
+ // Rename, take the new path
158
+ content = content[idx+4:]
159
+ }
160
+ path := strings.Trim(content, "\"")
161
+ filesToSnapshot[path] = true
162
+ }
163
+ }
164
+
165
+ for relPath := range filesToSnapshot {
166
+ fullPath := filepath.Join(gitRoot, relPath)
167
+
168
+ // Skip if not exist (deleted)
169
+ info, err := os.Stat(fullPath)
170
+ if err != nil {
171
+ continue
172
+ }
173
+
174
+ // Skip directories
175
+ if info.IsDir() {
176
+ continue
177
+ }
178
+
179
+ hash, err := hashFile(fullPath)
180
+ if err != nil {
181
+ continue
182
+ }
183
+
184
+ // Read content
185
+ contentBytes, err := os.ReadFile(fullPath)
186
+ if err != nil {
187
+ continue
188
+ }
189
+
190
+ snapshot.Files[relPath] = &FileState{
191
+ Path: relPath,
192
+ Hash: hash,
193
+ Size: info.Size(),
194
+ ModifiedTime: info.ModTime(),
195
+ Content: string(contentBytes),
196
+ }
197
+ }
198
+
199
+ return Save(gitRoot, snapshot)
200
+ }
201
+
87
202
  // Load loads an existing snapshot
88
203
  func Load(gitRoot string) (*Snapshot, error) {
89
204
  path := SnapshotPath(gitRoot)
@@ -146,6 +261,80 @@ type CompareResult struct {
146
261
 
147
262
  // Compare compares the current working directory to a snapshot
148
263
  func Compare(gitRoot string, snapshot *Snapshot) (*CompareResult, error) {
264
+ if snapshot.HeadCommit == "" {
265
+ return compareLegacy(gitRoot, snapshot)
266
+ }
267
+
268
+ result := &CompareResult{
269
+ ModifiedFiles: []string{},
270
+ DeletedFiles: []string{},
271
+ NewFiles: []string{},
272
+ }
273
+
274
+ candidates := make(map[string]bool)
275
+
276
+ // 1. Files in snapshot
277
+ for f := range snapshot.Files {
278
+ candidates[f] = true
279
+ }
280
+
281
+ // 2. Files changed relative to HeadCommit
282
+ // git diff --name-only HeadCommit
283
+ out, err := runGit(gitRoot, "diff", "--name-only", snapshot.HeadCommit)
284
+ if err == nil {
285
+ for _, f := range strings.Split(out, "\n") {
286
+ if f != "" {
287
+ candidates[f] = true
288
+ }
289
+ }
290
+ }
291
+
292
+ // 3. Untracked files
293
+ // git ls-files --others --exclude-standard
294
+ out, err = runGit(gitRoot, "ls-files", "--others", "--exclude-standard")
295
+ if err == nil {
296
+ for _, f := range strings.Split(out, "\n") {
297
+ if f != "" {
298
+ candidates[f] = true
299
+ }
300
+ }
301
+ }
302
+
303
+ for path := range candidates {
304
+ // Determine Current Hash
305
+ var currentHash string
306
+ fullPath := filepath.Join(gitRoot, path)
307
+ info, err := os.Stat(fullPath)
308
+ if err == nil && !info.IsDir() {
309
+ currentHash, _ = hashFile(fullPath)
310
+ }
311
+
312
+ // Determine Snapshot Hash
313
+ var snapshotHash string
314
+ if state, ok := snapshot.Files[path]; ok {
315
+ snapshotHash = state.Hash
316
+ } else {
317
+ // Not in snapshot, assume equal to HeadCommit
318
+ // If file didn't exist in HeadCommit, getHashAtCommit returns err/empty
319
+ snapshotHash, _ = getHashAtCommit(gitRoot, snapshot.HeadCommit, path)
320
+ }
321
+
322
+ if currentHash == "" && snapshotHash == "" {
323
+ continue
324
+ }
325
+ if currentHash == "" {
326
+ result.DeletedFiles = append(result.DeletedFiles, path)
327
+ } else if snapshotHash == "" {
328
+ result.NewFiles = append(result.NewFiles, path)
329
+ } else if currentHash != snapshotHash {
330
+ result.ModifiedFiles = append(result.ModifiedFiles, path)
331
+ }
332
+ }
333
+
334
+ return result, nil
335
+ }
336
+
337
+ func compareLegacy(gitRoot string, snapshot *Snapshot) (*CompareResult, error) {
149
338
  result := &CompareResult{
150
339
  ModifiedFiles: []string{},
151
340
  DeletedFiles: []string{},
@@ -310,31 +499,113 @@ type ChangedLine struct {
310
499
  func GetChangedLinesForChecker(gitRoot string, snapshot *Snapshot, compareResult *CompareResult) ([]ChangedLine, error) {
311
500
  var changes []ChangedLine
312
501
 
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)
502
+ // For modified files, we diff against the snapshot version
315
503
  for _, relPath := range compareResult.ModifiedFiles {
316
504
  fullPath := filepath.Join(gitRoot, relPath)
317
505
 
318
- // Read new content
506
+ // Get New Content (Current)
319
507
  newData, err := os.ReadFile(fullPath)
320
508
  if err != nil {
321
509
  continue
322
510
  }
323
-
324
- // Skip binary files
325
511
  if !isText(newData) {
326
512
  continue
327
513
  }
328
514
 
329
- newLines := strings.Split(string(newData), "\n")
515
+ // Get Old Content (Snapshot)
516
+ var oldData []byte
517
+
518
+ if fileState, ok := snapshot.Files[relPath]; ok && fileState.Content != "" {
519
+ // Was dirty -> use stored content
520
+ oldData = []byte(fileState.Content)
521
+ } else {
522
+ // Was clean -> use git show
523
+ // If it wasn't in snapshot.Files but is in ModifiedFiles, it must have been clean at HEAD
524
+ // and now is modified.
525
+ // Note: If snapshot.Files has it but Content is empty, that means it was clean but we stored it?
526
+ // No, CreateOptimized only stores Content for dirty files.
527
+ // But wait, CreateOptimized only adds files to snapshot.Files if they are dirty or targetted.
528
+ // If a file is NOT in snapshot.Files, it is assumed clean at HeadCommit.
529
+
530
+ // However, compareResult.ModifiedFiles includes files that:
531
+ // 1. Were in snapshot (hash changed)
532
+ // 2. Were NOT in snapshot (current hash != HEAD hash)
533
+
534
+ // Case 1: In snapshot
535
+ if fileState, ok := snapshot.Files[relPath]; ok {
536
+ if fileState.Content != "" {
537
+ oldData = []byte(fileState.Content)
538
+ } else {
539
+ // It was in snapshot but no content? This happens if it was > 1MB
540
+ // or if we used the legacy Create method which doesn't store content.
541
+ // In that case we fall back to reading from HEAD (imperfect but best effort)
542
+ // OR we just treat all lines as changed (current behavior) if we can't get old content.
543
+
544
+ // Try to get from HEAD just in case it matches
545
+ hash, _ := getHashAtCommit(gitRoot, snapshot.HeadCommit, relPath)
546
+ if hash == fileState.Hash {
547
+ // It matches HEAD, so we can get content from HEAD
548
+ out, err := runGit(gitRoot, "show", fmt.Sprintf("%s:%s", snapshot.HeadCommit, relPath))
549
+ if err == nil {
550
+ oldData = []byte(out)
551
+ }
552
+ }
553
+ }
554
+ } else {
555
+ // Case 2: Not in snapshot (was clean at HEAD)
556
+ out, err := runGit(gitRoot, "show", fmt.Sprintf("%s:%s", snapshot.HeadCommit, relPath))
557
+ if err == nil {
558
+ oldData = []byte(out)
559
+ }
560
+ }
561
+ }
330
562
 
331
- // Include all non-empty lines from modified files
332
- for i, line := range newLines {
333
- if strings.TrimSpace(line) != "" {
563
+ // If we couldn't get old data, fallback to treating all lines as new (safest)
564
+ if len(oldData) == 0 {
565
+ newLines := strings.Split(string(newData), "\n")
566
+ for i, line := range newLines {
567
+ if strings.TrimSpace(line) != "" {
568
+ changes = append(changes, ChangedLine{
569
+ File: relPath,
570
+ LineNumber: i + 1,
571
+ Content: line,
572
+ ChangeType: "+",
573
+ })
574
+ }
575
+ }
576
+ continue
577
+ }
578
+
579
+ // Do the diff
580
+ // Write to temp files
581
+ tmpOld, err := os.CreateTemp("", "tanagram-old-*")
582
+ if err != nil {
583
+ continue
584
+ }
585
+ defer os.Remove(tmpOld.Name())
586
+ tmpOld.Write(oldData)
587
+ tmpOld.Close()
588
+
589
+ tmpNew, err := os.CreateTemp("", "tanagram-new-*")
590
+ if err != nil {
591
+ continue
592
+ }
593
+ defer os.Remove(tmpNew.Name())
594
+ tmpNew.Write(newData)
595
+ tmpNew.Close()
596
+
597
+ diffResult, err := git.DiffFiles(tmpOld.Name(), tmpNew.Name(), relPath)
598
+ if err != nil {
599
+ continue
600
+ }
601
+
602
+ // Convert git.ChangedLine to snapshot.ChangedLine
603
+ for _, c := range diffResult.Changes {
604
+ if c.ChangeType == "+" {
334
605
  changes = append(changes, ChangedLine{
335
606
  File: relPath,
336
- LineNumber: i + 1,
337
- Content: line,
607
+ LineNumber: c.LineNumber,
608
+ Content: c.Content,
338
609
  ChangeType: "+",
339
610
  })
340
611
  }