返回 DeepSeek-Reasonix
merge_state.go
根目录 / internal / worktree / merge_state.go
1 package worktree
2
3 import (
4 "context"
5 "crypto/sha256"
6 "encoding/hex"
7 "errors"
8 "fmt"
9 "hash"
10 "io"
11 "os"
12 "path/filepath"
13 "sort"
14 "strings"
15
16 "reasonix/internal/gitcmd"
17 )
18
19 // worktreeStateToken fingerprints the real index and dirty filesystem state
20 // without modifying either. Porcelain and ls-files -z keep unusual paths
21 // unambiguous, while the index entries bind staged-only content and modes.
22 func worktreeStateToken(ctx context.Context, root gitcmd.Repo) (string, error) {
23 status, stderr, err := runGitEnv(ctx, root, gitNoOptionalLocks, "status", "--porcelain=v1", "-z", "--untracked-files=all")
24 if err != nil {
25 return "", fmt.Errorf("list changed paths: %w%s", err, stderrSuffix(stderr))
26 }
27 index, stderr, err := runGitEnv(ctx, root, gitNoOptionalLocks, "ls-files", "--stage", "-z")
28 if err != nil {
29 return "", fmt.Errorf("snapshot index entries: %w%s", err, stderrSuffix(stderr))
30 }
31 paths, err := nulStatusPaths(status)
32 if err != nil {
33 return "", err
34 }
35 hash := sha256.New()
36 _, _ = io.WriteString(hash, "reasonix-worktree-state-v3\x00status\x00")
37 _, _ = io.WriteString(hash, status)
38 _, _ = io.WriteString(hash, "\x00index\x00")
39 _, _ = io.WriteString(hash, index)
40 _, _ = io.WriteString(hash, "\x00filesystem\x00")
41 for _, relative := range paths {
42 if err := hashWorktreePath(ctx, hash, root.WorkTree, relative); err != nil {
43 return "", err
44 }
45 }
46 return hex.EncodeToString(hash.Sum(nil)), nil
47 }
48
49 func nulStatusPaths(status string) ([]string, error) {
50 records := strings.Split(status, "\x00")
51 seen := map[string]struct{}{}
52 paths := []string{}
53 for index := 0; index < len(records); index++ {
54 record := records[index]
55 if record == "" {
56 continue
57 }
58 if len(record) < 4 || record[2] != ' ' {
59 return nil, fmt.Errorf("unexpected Git status record %q", record)
60 }
61 path := record[3:]
62 if err := validateStatePath(path); err != nil {
63 return nil, err
64 }
65 if _, ok := seen[path]; !ok {
66 seen[path] = struct{}{}
67 paths = append(paths, path)
68 }
69 if record[0] == 'R' || record[0] == 'C' || record[1] == 'R' || record[1] == 'C' {
70 index++
71 if index >= len(records) || records[index] == "" {
72 return nil, errors.New("Git status rename record is incomplete")
73 }
74 oldPath := records[index]
75 if err := validateStatePath(oldPath); err != nil {
76 return nil, err
77 }
78 if _, ok := seen[oldPath]; !ok {
79 seen[oldPath] = struct{}{}
80 paths = append(paths, oldPath)
81 }
82 }
83 }
84 sort.Strings(paths)
85 return paths, nil
86 }
87
88 func validateStatePath(path string) error {
89 if path == "" || filepath.IsAbs(filepath.FromSlash(path)) {
90 return fmt.Errorf("unsafe changed path %q", path)
91 }
92 clean := filepath.Clean(filepath.FromSlash(path))
93 if clean == ".." || strings.HasPrefix(clean, ".."+string(filepath.Separator)) {
94 return fmt.Errorf("changed path escapes worktree: %q", path)
95 }
96 return nil
97 }
98
99 func hashWorktreePath(ctx context.Context, stateHash hash.Hash, root, relative string) error {
100 _, _ = io.WriteString(stateHash, "path\x00"+relative+"\x00")
101 path := filepath.Join(root, filepath.FromSlash(relative))
102 info, err := os.Lstat(path)
103 if errors.Is(err, os.ErrNotExist) {
104 _, _ = io.WriteString(stateHash, "deleted\x00")
105 return nil
106 }
107 if err != nil {
108 return fmt.Errorf("inspect changed path %q: %w", relative, err)
109 }
110 _, _ = io.WriteString(stateHash, info.Mode().String()+"\x00")
111 switch {
112 case info.Mode().IsRegular():
113 digest, err := digestWorktreeStateFile(ctx, path)
114 if err != nil {
115 return fmt.Errorf("digest changed path %q: %w", relative, err)
116 }
117 _, _ = io.WriteString(stateHash, digest)
118 case info.Mode()&os.ModeSymlink != 0:
119 target, err := os.Readlink(path)
120 if err != nil {
121 return fmt.Errorf("read changed symlink %q: %w", relative, err)
122 }
123 _, _ = io.WriteString(stateHash, target)
124 case info.IsDir():
125 // A nested repository: host git never runs inside it, and staging keeps
126 // it out of the commit, so its index entry above is what the token binds.
127 _, _ = io.WriteString(stateHash, "directory")
128 default:
129 return fmt.Errorf("changed path %q has unsupported file type %s", relative, info.Mode().Type())
130 }
131 _, _ = io.WriteString(stateHash, "\x00")
132 return nil
133 }
134
135 func digestWorktreeStateFile(ctx context.Context, path string) (string, error) {
136 file, err := os.Open(path)
137 if err != nil {
138 return "", err
139 }
140 defer file.Close()
141
142 digest := sha256.New()
143 buffer := make([]byte, 128*1024)
144 for {
145 if err := ctx.Err(); err != nil {
146 return "", err
147 }
148 count, readErr := file.Read(buffer)
149 if count > 0 {
150 if _, err := digest.Write(buffer[:count]); err != nil {
151 return "", err
152 }
153 }
154 if errors.Is(readErr, io.EOF) {
155 return hex.EncodeToString(digest.Sum(nil)), nil
156 }
157 if readErr != nil {
158 return "", readErr
159 }
160 }
161 }
162
163 func gitOperation(ctx context.Context, root gitcmd.Repo) (string, error) {
164 operations := []struct{ name, marker string }{
165 {"merge", "MERGE_HEAD"}, {"rebase", "rebase-merge"}, {"rebase", "rebase-apply"},
166 {"cherry-pick", "CHERRY_PICK_HEAD"}, {"revert", "REVERT_HEAD"}, {"bisect", "BISECT_LOG"},
167 }
168 for _, operation := range operations {
169 path, stderr, err := gitValue(ctx, root, "rev-parse", "--git-path", operation.marker)
170 if err != nil {
171 return "", fmt.Errorf("inspect Git operation %s: %w%s", operation.name, err, stderrSuffix(stderr))
172 }
173 if !filepath.IsAbs(path) {
174 path = filepath.Join(root.Dir, path)
175 }
176 if _, err := os.Stat(path); err == nil {
177 return operation.name, nil
178 } else if !errors.Is(err, os.ErrNotExist) {
179 return "", fmt.Errorf("inspect Git operation %s: %w", operation.name, err)
180 }
181 }
182 return "", nil
183 }
184
184 lines GO