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