@tanagram/cli 0.4.9 → 0.4.10

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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@tanagram/cli",
3
- "version": "0.4.9",
3
+ "version": "0.4.10",
4
4
  "description": "Tanagram - Catch sloppy code before it ships",
5
5
  "main": "index.js",
6
6
  "bin": {
@@ -1,11 +1,13 @@
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"
@@ -23,8 +25,9 @@ type FileState struct {
23
25
 
24
26
  // Snapshot represents a snapshot of the working directory
25
27
  type Snapshot struct {
26
- Timestamp time.Time `json:"timestamp"`
27
- Files map[string]*FileState `json:"files"`
28
+ Timestamp time.Time `json:"timestamp"`
29
+ HeadCommit string `json:"head_commit,omitempty"`
30
+ Files map[string]*FileState `json:"files"`
28
31
  }
29
32
 
30
33
  // SnapshotPath returns the path to the snapshot file
@@ -32,7 +35,36 @@ func SnapshotPath(gitRoot string) string {
32
35
  return filepath.Join(gitRoot, ".tanagram", "snapshot.json")
33
36
  }
34
37
 
38
+ // runGit executes a git command in the given directory
39
+ func runGit(dir string, args ...string) (string, error) {
40
+ cmd := exec.Command("git", args...)
41
+ cmd.Dir = dir
42
+ out, err := cmd.Output()
43
+ if err != nil {
44
+ return "", err
45
+ }
46
+ return string(bytes.TrimSpace(out)), nil
47
+ }
48
+
49
+ // getHashAtCommit returns the MD5 hash of a file at a specific commit
50
+ func getHashAtCommit(gitRoot, commit, path string) (string, error) {
51
+ // git show commit:path
52
+ cmd := exec.Command("git", "show", fmt.Sprintf("%s:%s", commit, path))
53
+ cmd.Dir = gitRoot
54
+
55
+ // We need to hash the output
56
+ h := md5.New()
57
+ cmd.Stdout = h
58
+
59
+ if err := cmd.Run(); err != nil {
60
+ return "", err
61
+ }
62
+
63
+ return fmt.Sprintf("%x", h.Sum(nil)), nil
64
+ }
65
+
35
66
  // Create creates a new snapshot of all tracked and modified files
67
+ // This is kept for backward compatibility or fallback
36
68
  func Create(gitRoot string) error {
37
69
  snapshot := &Snapshot{
38
70
  Timestamp: time.Now(),
@@ -84,6 +116,80 @@ func Create(gitRoot string) error {
84
116
  return Save(gitRoot, snapshot)
85
117
  }
86
118
 
119
+ // CreateOptimized creates a snapshot using git status/target files
120
+ func CreateOptimized(gitRoot string, targetFiles []string) error {
121
+ // Get current HEAD
122
+ headCommit, err := runGit(gitRoot, "rev-parse", "HEAD")
123
+ if err != nil {
124
+ // Fallback to full create if no git/head
125
+ return Create(gitRoot)
126
+ }
127
+
128
+ snapshot := &Snapshot{
129
+ Timestamp: time.Now(),
130
+ HeadCommit: headCommit,
131
+ Files: make(map[string]*FileState),
132
+ }
133
+
134
+ // Use a map to deduplicate files
135
+ filesToSnapshot := make(map[string]bool)
136
+
137
+ // 1. Add target files (if provided)
138
+ for _, f := range targetFiles {
139
+ filesToSnapshot[f] = true
140
+ }
141
+
142
+ // 2. Add all currently dirty files (modified/staged/untracked)
143
+ // This ensures that if the user has uncommitted changes, we record them
144
+ // so that we don't falsely attribute them to the agent later.
145
+ out, err := runGit(gitRoot, "status", "--porcelain")
146
+ if err == nil {
147
+ lines := strings.Split(out, "\n")
148
+ for _, line := range lines {
149
+ if len(line) < 4 {
150
+ continue
151
+ }
152
+ // Format: XY PATH or XY PATH -> NEWPATH
153
+ content := line[3:]
154
+ if idx := strings.Index(content, " -> "); idx != -1 {
155
+ // Rename, take the new path
156
+ content = content[idx+4:]
157
+ }
158
+ path := strings.Trim(content, "\"")
159
+ filesToSnapshot[path] = true
160
+ }
161
+ }
162
+
163
+ for relPath := range filesToSnapshot {
164
+ fullPath := filepath.Join(gitRoot, relPath)
165
+
166
+ // Skip if not exist (deleted)
167
+ info, err := os.Stat(fullPath)
168
+ if err != nil {
169
+ continue
170
+ }
171
+
172
+ // Skip directories
173
+ if info.IsDir() {
174
+ continue
175
+ }
176
+
177
+ hash, err := hashFile(fullPath)
178
+ if err != nil {
179
+ continue
180
+ }
181
+
182
+ snapshot.Files[relPath] = &FileState{
183
+ Path: relPath,
184
+ Hash: hash,
185
+ Size: info.Size(),
186
+ ModifiedTime: info.ModTime(),
187
+ }
188
+ }
189
+
190
+ return Save(gitRoot, snapshot)
191
+ }
192
+
87
193
  // Load loads an existing snapshot
88
194
  func Load(gitRoot string) (*Snapshot, error) {
89
195
  path := SnapshotPath(gitRoot)
@@ -146,6 +252,80 @@ type CompareResult struct {
146
252
 
147
253
  // Compare compares the current working directory to a snapshot
148
254
  func Compare(gitRoot string, snapshot *Snapshot) (*CompareResult, error) {
255
+ if snapshot.HeadCommit == "" {
256
+ return compareLegacy(gitRoot, snapshot)
257
+ }
258
+
259
+ result := &CompareResult{
260
+ ModifiedFiles: []string{},
261
+ DeletedFiles: []string{},
262
+ NewFiles: []string{},
263
+ }
264
+
265
+ candidates := make(map[string]bool)
266
+
267
+ // 1. Files in snapshot
268
+ for f := range snapshot.Files {
269
+ candidates[f] = true
270
+ }
271
+
272
+ // 2. Files changed relative to HeadCommit
273
+ // git diff --name-only HeadCommit
274
+ out, err := runGit(gitRoot, "diff", "--name-only", snapshot.HeadCommit)
275
+ if err == nil {
276
+ for _, f := range strings.Split(out, "\n") {
277
+ if f != "" {
278
+ candidates[f] = true
279
+ }
280
+ }
281
+ }
282
+
283
+ // 3. Untracked files
284
+ // git ls-files --others --exclude-standard
285
+ out, err = runGit(gitRoot, "ls-files", "--others", "--exclude-standard")
286
+ if err == nil {
287
+ for _, f := range strings.Split(out, "\n") {
288
+ if f != "" {
289
+ candidates[f] = true
290
+ }
291
+ }
292
+ }
293
+
294
+ for path := range candidates {
295
+ // Determine Current Hash
296
+ var currentHash string
297
+ fullPath := filepath.Join(gitRoot, path)
298
+ info, err := os.Stat(fullPath)
299
+ if err == nil && !info.IsDir() {
300
+ currentHash, _ = hashFile(fullPath)
301
+ }
302
+
303
+ // Determine Snapshot Hash
304
+ var snapshotHash string
305
+ if state, ok := snapshot.Files[path]; ok {
306
+ snapshotHash = state.Hash
307
+ } else {
308
+ // Not in snapshot, assume equal to HeadCommit
309
+ // If file didn't exist in HeadCommit, getHashAtCommit returns err/empty
310
+ snapshotHash, _ = getHashAtCommit(gitRoot, snapshot.HeadCommit, path)
311
+ }
312
+
313
+ if currentHash == "" && snapshotHash == "" {
314
+ continue
315
+ }
316
+ if currentHash == "" {
317
+ result.DeletedFiles = append(result.DeletedFiles, path)
318
+ } else if snapshotHash == "" {
319
+ result.NewFiles = append(result.NewFiles, path)
320
+ } else if currentHash != snapshotHash {
321
+ result.ModifiedFiles = append(result.ModifiedFiles, path)
322
+ }
323
+ }
324
+
325
+ return result, nil
326
+ }
327
+
328
+ func compareLegacy(gitRoot string, snapshot *Snapshot) (*CompareResult, error) {
149
329
  result := &CompareResult{
150
330
  ModifiedFiles: []string{},
151
331
  DeletedFiles: []string{},