返回 DeepSeek-Reasonix
merge_autocommit.go
根目录 / internal / worktree / merge_autocommit.go
1 package worktree
2
3 import (
4 "bytes"
5 "context"
6 "errors"
7 "fmt"
8 "io"
9 "os"
10 "path/filepath"
11 "strings"
12
13 "reasonix/internal/fileutil"
14 "reasonix/internal/gitcmd"
15 )
16
17 var gitNoOptionalLocks = []string{"GIT_OPTIONAL_LOCKS=0"}
18
19 // autoCommitDirtyWorktree commits the confirmed filesystem snapshot without
20 // exposing the user's real index to git add or hooks. The branch ref and index
21 // are installed through separate compare-and-apply gates; failures after the
22 // ref CAS are explicitly recovery-required.
23 func autoCommitDirtyWorktree(ctx context.Context, inspection MergeInspection) (string, bool, error) {
24 if err := verifyWorktreeMergeIdentity(ctx, inspection); err != nil {
25 return "", false, fmt.Errorf("worktree changed before auto-commit: %w", err)
26 }
27 indexPath, originalIndex, err := snapshotRealIndex(ctx, inspection.worktree)
28 if err != nil {
29 return "", false, err
30 }
31 realEntries, stderr, err := runGitEnv(ctx, inspection.worktree, gitNoOptionalLocks, "ls-files", "--stage", "-z")
32 if err != nil {
33 return "", false, fmt.Errorf("snapshot real index entries: %w%s", err, stderrSuffix(stderr))
34 }
35 stagedIndexChanges, err := hasStagedIndexChanges(ctx, inspection.worktree, inspection.WorktreeHead)
36 if err != nil {
37 return "", false, err
38 }
39
40 tempIndex, err := newTemporaryIndex(inspection.WorktreeRoot)
41 if err != nil {
42 return "", false, err
43 }
44 defer os.Remove(tempIndex)
45 tempEnv := []string{"GIT_OPTIONAL_LOCKS=0", "GIT_INDEX_FILE=" + tempIndex}
46 if _, stderr, err := runGitEnv(ctx, inspection.worktree, tempEnv, "read-tree", inspection.WorktreeHead); err != nil {
47 return "", false, fmt.Errorf("seed temporary index: %w%s", err, stderrSuffix(stderr))
48 }
49 if err := secureTemporaryIndex(tempIndex); err != nil {
50 return "", false, err
51 }
52 if err := inspection.worktree.StageAll(ctx, tempEnv...); err != nil {
53 return "", false, fmt.Errorf("stage confirmed changes in temporary index: %w", err)
54 }
55 if err := secureTemporaryIndex(tempIndex); err != nil {
56 return "", false, err
57 }
58 noteMergeStep("after_worktree_add")
59 stagedTree, tempEntries, err := verifyTemporaryIndex(ctx, inspection, tempEnv)
60 if err != nil {
61 return "", false, fmt.Errorf("worktree changed while staging; the real index was preserved: %w", err)
62 }
63 if stagedIndexChanges && realEntries != tempEntries {
64 return "", false, errors.New("worktree_index_split: staged or index-only content is not fully represented by the working tree; commit, stash, or unstage it manually")
65 }
66 headTree, stderr, err := gitValue(ctx, inspection.worktree, "rev-parse", "--verify", inspection.WorktreeHead+"^{tree}")
67 if err != nil {
68 return "", false, fmt.Errorf("read confirmed worktree tree: %w%s", err, stderrSuffix(stderr))
69 }
70 if stagedTree == headTree {
71 return "", false, errors.New("confirmed worktree snapshot no longer contains committable changes; inspect again")
72 }
73
74 committedHead, stderr, err := gitValue(ctx, inspection.worktree,
75 "-c", "user.name=Reasonix", "-c", "user.email=reasonix@local",
76 "commit-tree", stagedTree, "-p", inspection.WorktreeHead, "-m", "worktree: save changes before merge back")
77 if err != nil {
78 return "", false, fmt.Errorf("create exact worktree commit: %w%s", err, stderrSuffix(stderr))
79 }
80 noteMergeStep("after_worktree_commit")
81 if err := verifyCommitObject(ctx, inspection.worktree, committedHead, inspection.WorktreeHead, stagedTree); err != nil {
82 return "", false, fmt.Errorf("auto-commit object identity changed: %w", err)
83 }
84 if err := verifyWorktreeMergeIdentity(ctx, inspection); err != nil {
85 return "", false, fmt.Errorf("worktree changed before auto-commit ref update: %w", err)
86 }
87 noteMergeStep("before_worktree_ref_transaction")
88 if err := verifyWorktreeMergeIdentity(ctx, inspection); err != nil {
89 return "", false, fmt.Errorf("worktree changed at auto-commit ref update: %w", err)
90 }
91 branchRef := "refs/heads/" + inspection.WorktreeBranch
92 input := fmt.Sprintf("update %s %s %s\n", branchRef, committedHead, inspection.WorktreeHead)
93 if _, stderr, err := runGitInput(ctx, inspection.worktree, input, "update-ref", "--stdin"); err != nil {
94 installed, verifyErr := refEquals(ctx, inspection.worktree, branchRef, committedHead)
95 if verifyErr != nil || installed {
96 return "", true, fmt.Errorf("auto-commit ref transaction is uncertain; recovery is required: %w%s", err, stderrSuffix(stderr))
97 }
98 return "", false, fmt.Errorf("worktree branch changed before auto-commit ref update; the real index was preserved: %w%s", err, stderrSuffix(stderr))
99 }
100 noteMergeStep("after_worktree_ref_update")
101 if err := verifyWorktreeCheckout(ctx, inspection, committedHead); err != nil {
102 return "", true, fmt.Errorf("auto-commit ref was installed but checkout identity changed; recovery is required: %w", err)
103 }
104 if err := verifyTemporaryIndexAgainstWorktree(ctx, inspection.worktree, tempEnv, stagedTree); err != nil {
105 return "", true, fmt.Errorf("auto-commit ref was installed but worktree contents changed; recovery is required: %w", err)
106 }
107 noteMergeStep("before_worktree_index_sync")
108 if err := installIndexFileCAS(indexPath, tempIndex, originalIndex); err != nil {
109 return "", true, fmt.Errorf("auto-commit ref was installed but the real index changed; recovery is required: %w", err)
110 }
111 noteMergeStep("after_worktree_index_sync")
112 if err := verifyAutoCommitSuccess(ctx, inspection, committedHead, stagedTree); err != nil {
113 return "", true, fmt.Errorf("auto-commit was installed but final verification failed; recovery is required: %w", err)
114 }
115 return committedHead, false, nil
116 }
117
118 func newTemporaryIndex(worktreeRoot string) (string, error) {
119 file, err := os.CreateTemp(filepath.Dir(worktreeRoot), ".reasonix-merge-index-*")
120 if err != nil {
121 return "", fmt.Errorf("allocate temporary index: %w", err)
122 }
123 path := file.Name()
124 if err := file.Close(); err != nil {
125 _ = os.Remove(path)
126 return "", fmt.Errorf("close temporary index placeholder: %w", err)
127 }
128 if err := os.Remove(path); err != nil {
129 return "", fmt.Errorf("prepare temporary index path: %w", err)
130 }
131 return path, nil
132 }
133
134 func secureTemporaryIndex(path string) error {
135 if err := os.Chmod(path, 0o600); err != nil {
136 return fmt.Errorf("secure temporary index: %w", err)
137 }
138 return nil
139 }
140
141 func snapshotRealIndex(ctx context.Context, root gitcmd.Repo) (string, []byte, error) {
142 indexPath, stderr, err := gitValue(ctx, root, "rev-parse", "--git-path", "index")
143 if err != nil {
144 return "", nil, fmt.Errorf("resolve real index path: %w%s", err, stderrSuffix(stderr))
145 }
146 if !filepath.IsAbs(indexPath) {
147 indexPath = filepath.Join(root.Dir, indexPath)
148 }
149 body, err := os.ReadFile(indexPath)
150 if err != nil {
151 return "", nil, fmt.Errorf("snapshot real index: %w", err)
152 }
153 return filepath.Clean(indexPath), body, nil
154 }
155
156 func verifyTemporaryIndex(ctx context.Context, inspection MergeInspection, env []string) (string, string, error) {
157 if err := verifyWorktreeMergeIdentity(ctx, inspection); err != nil {
158 return "", "", err
159 }
160 tree, stderr, err := gitValueEnv(ctx, inspection.worktree, env, "write-tree")
161 if err != nil {
162 return "", "", fmt.Errorf("record temporary index tree: %w%s", err, stderrSuffix(stderr))
163 }
164 if err := verifyTemporaryIndexAgainstWorktree(ctx, inspection.worktree, env, tree); err != nil {
165 return "", "", err
166 }
167 entries, stderr, err := runGitEnv(ctx, inspection.worktree, env, "ls-files", "--stage", "-z")
168 if err != nil {
169 return "", "", fmt.Errorf("snapshot temporary index entries: %w%s", err, stderrSuffix(stderr))
170 }
171 return tree, entries, nil
172 }
173
174 func verifyTemporaryIndexAgainstWorktree(ctx context.Context, root gitcmd.Repo, env []string, expectedTree string) error {
175 if _, stderr, err := runGitEnv(ctx, root, env, "diff", "--quiet", "--"); err != nil {
176 if exitCode(err) == 1 {
177 return errors.New("working tree differs from the temporary index")
178 }
179 return fmt.Errorf("verify temporary index worktree: %w%s", err, stderrSuffix(stderr))
180 }
181 untracked, stderr, err := runGitEnv(ctx, root, env, "ls-files", "--others", "--exclude-standard", "-z")
182 if err != nil {
183 return fmt.Errorf("inspect untracked files with temporary index: %w%s", err, stderrSuffix(stderr))
184 }
185 if untracked != "" {
186 return errors.New("untracked files remain outside the temporary index")
187 }
188 tree, stderr, err := gitValueEnv(ctx, root, env, "write-tree")
189 if err != nil {
190 return fmt.Errorf("re-read temporary index tree: %w%s", err, stderrSuffix(stderr))
191 }
192 if tree != expectedTree {
193 return errors.New("temporary index tree changed while verifying the worktree")
194 }
195 return nil
196 }
197
198 func installIndexFileCAS(indexPath, preparedPath string, expected []byte) (err error) {
199 info, err := os.Stat(indexPath)
200 if err != nil {
201 return fmt.Errorf("inspect real index: %w", err)
202 }
203 lockPath := indexPath + ".lock"
204 lock, err := os.OpenFile(lockPath, os.O_WRONLY|os.O_CREATE|os.O_EXCL, info.Mode().Perm())
205 if err != nil {
206 return fmt.Errorf("lock real index: %w", err)
207 }
208 committed := false
209 defer func() {
210 if !committed {
211 _ = lock.Close()
212 _ = os.Remove(lockPath)
213 }
214 }()
215 current, err := os.ReadFile(indexPath)
216 if err != nil {
217 return fmt.Errorf("re-read real index: %w", err)
218 }
219 if !bytes.Equal(current, expected) {
220 return errors.New("real index bytes no longer match the confirmed snapshot")
221 }
222 prepared, err := os.Open(preparedPath)
223 if err != nil {
224 return fmt.Errorf("open prepared index: %w", err)
225 }
226 _, copyErr := io.Copy(lock, prepared)
227 closePreparedErr := prepared.Close()
228 if copyErr != nil {
229 return fmt.Errorf("copy prepared index: %w", copyErr)
230 }
231 if closePreparedErr != nil {
232 return fmt.Errorf("close prepared index: %w", closePreparedErr)
233 }
234 if err := lock.Sync(); err != nil {
235 return fmt.Errorf("sync prepared index: %w", err)
236 }
237 if err := lock.Close(); err != nil {
238 return fmt.Errorf("close prepared index: %w", err)
239 }
240 if err := fileutil.ClaimRename(lockPath, indexPath); err != nil {
241 return fmt.Errorf("install prepared index: %w", err)
242 }
243 committed = true
244 return nil
245 }
246
247 func verifyCommitObject(ctx context.Context, root gitcmd.Repo, commit, expectedParent, expectedTree string) error {
248 line, stderr, err := gitValue(ctx, root, "rev-list", "--parents", "-n", "1", commit)
249 if err != nil {
250 return fmt.Errorf("read auto-commit parents: %w%s", err, stderrSuffix(stderr))
251 }
252 fields := strings.Fields(line)
253 if len(fields) != 2 || fields[0] != commit || fields[1] != expectedParent {
254 return errors.New("auto-commit does not have the confirmed HEAD as its unique parent")
255 }
256 tree, stderr, err := gitValue(ctx, root, "rev-parse", "--verify", commit+"^{tree}")
257 if err != nil {
258 return fmt.Errorf("read auto-commit tree: %w%s", err, stderrSuffix(stderr))
259 }
260 if tree != expectedTree {
261 return errors.New("auto-commit tree differs from the confirmed temporary index tree")
262 }
263 return nil
264 }
265
266 func verifyWorktreeCheckout(ctx context.Context, inspection MergeInspection, expectedHead string) error {
267 branch, stderr, err := gitValue(ctx, inspection.worktree, "symbolic-ref", "--quiet", "--short", "HEAD")
268 if err != nil || branch != inspection.WorktreeBranch {
269 return fmt.Errorf("worktree branch is %q, expected %q%s", branch, inspection.WorktreeBranch, stderrSuffix(stderr))
270 }
271 branchHead, stderr, err := gitValue(ctx, inspection.worktree, "rev-parse", "--verify", "refs/heads/"+inspection.WorktreeBranch)
272 if err != nil || branchHead != expectedHead {
273 return fmt.Errorf("worktree branch HEAD is %s, expected %s%s", branchHead, expectedHead, stderrSuffix(stderr))
274 }
275 head, stderr, err := gitValue(ctx, inspection.worktree, "rev-parse", "--verify", "HEAD")
276 if err != nil || head != expectedHead {
277 return fmt.Errorf("worktree HEAD is %s, expected %s%s", head, expectedHead, stderrSuffix(stderr))
278 }
279 operation, err := gitOperation(ctx, inspection.worktree)
280 if err != nil {
281 return err
282 }
283 if operation != "" {
284 return fmt.Errorf("worktree Git %s operation is in progress", operation)
285 }
286 return nil
287 }
288
289 func verifyAutoCommitSuccess(ctx context.Context, inspection MergeInspection, committedHead, expectedTree string) error {
290 if err := verifyWorktreeCheckout(ctx, inspection, committedHead); err != nil {
291 return err
292 }
293 if err := verifyCommitObject(ctx, inspection.worktree, committedHead, inspection.WorktreeHead, expectedTree); err != nil {
294 return err
295 }
296 status, stderr, err := runGitEnv(ctx, inspection.worktree, gitNoOptionalLocks, "status", "--porcelain=v1", "--untracked-files=all")
297 if err != nil {
298 return fmt.Errorf("verify auto-commit status: %w%s", err, stderrSuffix(stderr))
299 }
300 if strings.TrimSpace(status) != "" {
301 return errors.New("worktree is not clean after auto-commit")
302 }
303 return nil
304 }
305
306 func gitValueEnv(ctx context.Context, root gitcmd.Repo, env []string, args ...string) (string, string, error) {
307 out, stderr, err := runGitEnv(ctx, root, env, args...)
308 return strings.TrimSpace(out), stderr, err
309 }
310
311 func hasStagedIndexChanges(ctx context.Context, root gitcmd.Repo, head string) (bool, error) {
312 _, stderr, err := runGitEnv(ctx, root, gitNoOptionalLocks, "diff", "--cached", "--quiet", head, "--")
313 if err == nil {
314 return false, nil
315 }
316 if exitCode(err) == 1 {
317 return true, nil
318 }
319 return false, fmt.Errorf("inspect staged index changes: %w%s", err, stderrSuffix(stderr))
320 }
321
322 func refEquals(ctx context.Context, root gitcmd.Repo, ref, expected string) (bool, error) {
323 value, stderr, err := gitValue(ctx, root, "rev-parse", "--verify", ref)
324 if err != nil {
325 return false, fmt.Errorf("read ref %s: %w%s", ref, err, stderrSuffix(stderr))
326 }
327 return value == expected, nil
328 }
329
329 lines GO