@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.
- package/commands/snapshot.go +24 -2
- package/git/diff.go +34 -0
- package/package.json +1 -1
- package/snapshot/snapshot.go +284 -13
package/commands/snapshot.go
CHANGED
|
@@ -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
|
-
|
|
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 :=
|
|
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
package/snapshot/snapshot.go
CHANGED
|
@@ -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
|
|
27
|
-
|
|
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
|
|
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
|
-
//
|
|
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
|
-
|
|
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
|
-
//
|
|
332
|
-
|
|
333
|
-
|
|
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:
|
|
337
|
-
Content:
|
|
607
|
+
LineNumber: c.LineNumber,
|
|
608
|
+
Content: c.Content,
|
|
338
609
|
ChangeType: "+",
|
|
339
610
|
})
|
|
340
611
|
}
|