返回 DeepSeek-Reasonix
repoprograms_test.go
根目录 / internal / gitcmd / repoprograms_test.go
1 package gitcmd
2
3 import (
4 "context"
5 "errors"
6 "os"
7 "os/exec"
8 "path/filepath"
9 "runtime"
10 "strings"
11 "testing"
12 "time"
13 )
14
15 // Host git never runs a program named by the repository's own configuration.
16 // Each probe below runs the payload under stock git; each asserts the marker
17 // stays absent and the output still reports the change.
18
19 type repoFixture struct {
20 t *testing.T
21 dir string
22 marker string
23 ctx context.Context
24 }
25
26 func requirePOSIXGit(t *testing.T) {
27 t.Helper()
28 if runtime.GOOS == "windows" {
29 t.Skip("payload script is POSIX shell")
30 }
31 if _, err := exec.LookPath("git"); err != nil {
32 t.Skip("git not installed")
33 }
34 }
35
36 // newRepoFixture commits files (path -> content) with the given
37 // .gitattributes, using plain git: the fixture is setup, not the subject.
38 func newRepoFixture(t *testing.T, attributes string, files map[string]string) *repoFixture {
39 t.Helper()
40 requirePOSIXGit(t)
41 ctx, cancel := context.WithTimeout(context.Background(), 60*time.Second)
42 t.Cleanup(cancel)
43 f := &repoFixture{t: t, dir: t.TempDir(), marker: filepath.Join(t.TempDir(), "executed"), ctx: ctx}
44 f.plain("init", "--quiet", "-b", "main")
45 f.plain("config", "user.email", "test@example.com")
46 f.plain("config", "user.name", "test")
47 f.plain("config", "commit.gpgsign", "false")
48 if attributes != "" {
49 f.write(".gitattributes", attributes)
50 }
51 for path, content := range files {
52 f.write(path, content)
53 }
54 f.plain("add", "-A")
55 f.plain("commit", "--quiet", "-m", "initial")
56 return f
57 }
58
59 func (f *repoFixture) plain(args ...string) string {
60 f.t.Helper()
61 cmd := exec.CommandContext(f.ctx, "git", append([]string{"-C", f.dir}, args...)...)
62 out, err := cmd.CombinedOutput()
63 if err != nil {
64 f.t.Fatalf("setup git %v: %v: %s", args, err, out)
65 }
66 return string(out)
67 }
68
69 func (f *repoFixture) write(rel, content string) {
70 f.t.Helper()
71 path := filepath.Join(f.dir, filepath.FromSlash(rel))
72 if err := os.MkdirAll(filepath.Dir(path), 0o755); err != nil {
73 f.t.Fatal(err)
74 }
75 if err := os.WriteFile(path, []byte(content), 0o644); err != nil {
76 f.t.Fatal(err)
77 }
78 }
79
80 func (f *repoFixture) appendConfig(rel, text string) {
81 f.t.Helper()
82 path := filepath.Join(f.dir, ".git", filepath.FromSlash(rel))
83 fh, err := os.OpenFile(path, os.O_APPEND|os.O_CREATE|os.O_WRONLY, 0o644)
84 if err != nil {
85 f.t.Fatal(err)
86 }
87 defer fh.Close()
88 if _, err := fh.WriteString(text); err != nil {
89 f.t.Fatal(err)
90 }
91 }
92
93 // payload writes a script that records its run and passes stdin through, so a
94 // run that is not stopped still produces plausible git output.
95 func (f *repoFixture) payload() string {
96 f.t.Helper()
97 path := filepath.Join(f.t.TempDir(), "payload.sh")
98 script := "#!/bin/sh\necho ran >> '" + f.marker + "'\ncat\n"
99 if err := os.WriteFile(path, []byte(script), 0o755); err != nil {
100 f.t.Fatal(err)
101 }
102 return path
103 }
104
105 // makeStatDirty rewrites rel with same-length content and an old mtime, which
106 // forces git to re-hash the file — through its filter — to decide the answer.
107 func (f *repoFixture) makeStatDirty(rel, content string) {
108 f.t.Helper()
109 f.write(rel, content)
110 old := time.Now().Add(-48 * time.Hour)
111 if err := os.Chtimes(filepath.Join(f.dir, rel), old, old); err != nil {
112 f.t.Fatal(err)
113 }
114 }
115
116 func (f *repoFixture) run(args ...string) (string, error) {
117 f.t.Helper()
118 out, err := Command(f.ctx, "", append([]string{"-C", f.dir}, args...)...).CombinedOutput()
119 return string(out), err
120 }
121
122 func (f *repoFixture) mustRun(args ...string) string {
123 f.t.Helper()
124 out, err := f.run(args...)
125 if err != nil {
126 f.t.Fatalf("host git %v: %v: %s", args, err, out)
127 }
128 return out
129 }
130
131 func (f *repoFixture) assertNotExecuted() {
132 f.t.Helper()
133 if data, err := os.ReadFile(f.marker); err == nil {
134 f.t.Fatalf("host git ran a repository-configured program (%d runs)", strings.Count(string(data), "ran"))
135 } else if !os.IsNotExist(err) {
136 f.t.Fatalf("stat marker: %v", err)
137 }
138 }
139
140 func TestHostStatusAndDiffDoNotRunRepositoryFilters(t *testing.T) {
141 for _, tt := range []struct {
142 name string
143 config func(f *repoFixture, payload string)
144 }{
145 {"subsection", func(f *repoFixture, p string) {
146 f.appendConfig("config", "[filter \"pwn\"]\n\tclean = "+p+"\n\tprocess = "+p+"\n\trequired = true\n")
147 }},
148 {"legacy dotted section", func(f *repoFixture, p string) {
149 f.appendConfig("config", "[filter.pwn]\n\tclean = "+p+"\n")
150 }},
151 {"included file", func(f *repoFixture, p string) {
152 f.appendConfig("extra.cfg", "[filter \"pwn\"]\n\tclean = "+p+"\n")
153 f.appendConfig("config", "[include]\n\tpath = extra.cfg\n")
154 }},
155 {"worktree scope", func(f *repoFixture, p string) {
156 f.plain("config", "extensions.worktreeConfig", "true")
157 f.appendConfig("config.worktree", "[filter \"pwn\"]\n\tclean = "+p+"\n")
158 }},
159 } {
160 t.Run(tt.name, func(t *testing.T) {
161 f := newRepoFixture(t, "f.txt filter=pwn\n", map[string]string{"f.txt": "hello\n", "sub/g.txt": "g\n"})
162 tt.config(f, f.payload())
163 f.makeStatDirty("f.txt", "hellx\n")
164
165 if out := f.mustRun("status", "--porcelain=v1"); !strings.Contains(out, " M f.txt") {
166 t.Fatalf("status = %q, want the same-size edit reported", out)
167 }
168 if out := f.mustRun("diff", "--numstat", "HEAD", "--"); !strings.Contains(out, "1\t1\tf.txt") {
169 t.Fatalf("numstat = %q, want 1/1 for f.txt", out)
170 }
171 if out, err := Command(f.ctx, f.dir, "diff", "HEAD", "--", "f.txt").CombinedOutput(); err != nil || !strings.Contains(string(out), "+hellx") {
172 t.Fatalf("diff = %q, %v; want the working-tree line", out, err)
173 }
174 if out, err := Command(f.ctx, filepath.Join(f.dir, "sub"), "status", "--porcelain=v1").CombinedOutput(); err != nil || !strings.Contains(string(out), "f.txt") {
175 t.Fatalf("status from a subdirectory = %q, %v", out, err)
176 }
177 f.mustRun("ls-files", "--modified")
178 f.assertNotExecuted()
179 })
180 }
181 }
182
183 func TestHostHistoryReadsDoNotRunDiffDriversOrSignatureVerifiers(t *testing.T) {
184 f := newRepoFixture(t, "f.txt diff=tc\n", map[string]string{"f.txt": "one\n"})
185 p := f.payload()
186 f.appendConfig("config", "[diff \"tc\"]\n\ttextconv = "+p+"\n\tcommand = "+p+"\n[diff]\n\texternal = "+p+
187 "\n[log]\n\tshowSignature = true\n[gpg]\n\tprogram = "+p+"\n[gpg \"ssh\"]\n\tprogram = "+p+"\n")
188 f.write("f.txt", "two\n")
189 f.plain("-c", "diff.external=", "commit", "--quiet", "-am", "second")
190 signCommitHeader(f)
191
192 for _, args := range [][]string{
193 {"log", "-p", "-1"},
194 {"show", "HEAD"},
195 {"diff", "HEAD~1", "HEAD"},
196 } {
197 if out := f.mustRun(args...); !strings.Contains(out, "+two") {
198 t.Fatalf("git %v = %q, want the committed change", args, out)
199 }
200 }
201 f.mustRun("log", "--format=%H %s", "-1")
202 f.assertNotExecuted()
203 }
204
205 // signCommitHeader rewrites HEAD to carry a gpgsig header, the input that makes
206 // log.showSignature call the configured verifier.
207 func signCommitHeader(f *repoFixture) {
208 f.t.Helper()
209 raw := f.plain("cat-file", "commit", "HEAD")
210 header, body, _ := strings.Cut(raw, "\n\n")
211 signed := header + "\ngpgsig -----BEGIN PGP SIGNATURE-----\n \n AAAA\n -----END PGP SIGNATURE-----\n\n" + body
212 cmd := exec.CommandContext(f.ctx, "git", "-C", f.dir, "hash-object", "-t", "commit", "-w", "--stdin")
213 cmd.Stdin = strings.NewReader(signed)
214 out, err := cmd.Output()
215 if err != nil {
216 f.t.Fatalf("hash signed commit: %v", err)
217 }
218 f.plain("update-ref", "HEAD", strings.TrimSpace(string(out)))
219 }
220
221 func TestHostSuperprojectInspectionDoesNotRunSubmoduleDrivers(t *testing.T) {
222 sub := newRepoFixture(t, "s.txt filter=pwn\n", map[string]string{"s.txt": "hello\n"})
223 f := newRepoFixture(t, "", map[string]string{"top.txt": "top\n"})
224 f.plain("-c", "protocol.file.allow=always", "submodule", "--quiet", "add", sub.dir, "sm")
225 f.plain("commit", "--quiet", "-m", "add submodule")
226 f.appendConfig("modules/sm/config", "[filter \"pwn\"]\n\tclean = "+f.payload()+"\n")
227 f.makeStatDirty("sm/s.txt", "hellx\n")
228 f.write("top.txt", "changed\n")
229
230 if out := f.mustRun("status", "--porcelain=v1"); !strings.Contains(out, " M top.txt") {
231 t.Fatalf("status = %q, want the superproject change", out)
232 }
233 f.mustRun("diff", "--numstat", "HEAD", "--")
234 f.assertNotExecuted()
235 }
236
237 func TestHostRepositoryMutationsDoNotRunRepositoryPrograms(t *testing.T) {
238 f := newRepoFixture(t, "f.txt filter=pwn merge=pwn\n", map[string]string{"f.txt": "a\nb\nc\n"})
239 f.plain("checkout", "--quiet", "-b", "side")
240 f.write("f.txt", "a\nb\nX\n")
241 f.plain("commit", "--quiet", "-am", "side")
242 f.plain("checkout", "--quiet", "main")
243 f.write("f.txt", "Y\nb\nc\n")
244 f.plain("commit", "--quiet", "-am", "main")
245 p := f.payload()
246 hooks := filepath.Join(f.dir, ".git", "hooks")
247 if err := os.MkdirAll(hooks, 0o755); err != nil {
248 t.Fatal(err)
249 }
250 for _, hook := range []string{"post-checkout", "reference-transaction", "post-merge", "pre-merge-commit", "pre-commit", "pre-auto-gc"} {
251 if err := os.Symlink(p, filepath.Join(hooks, hook)); err != nil {
252 t.Fatal(err)
253 }
254 }
255 f.appendConfig("config", "[filter \"pwn\"]\n\tclean = "+p+"\n\tsmudge = "+p+"\n\tprocess = "+p+
256 "\n[merge \"pwn\"]\n\tdriver = "+p+" %O %A %B\n")
257
258 linked := filepath.Join(t.TempDir(), "linked")
259 f.mustRun("worktree", "add", "--quiet", "--detach", linked, "HEAD")
260 if data, err := os.ReadFile(filepath.Join(linked, "f.txt")); err != nil || string(data) != "Y\nb\nc\n" {
261 t.Fatalf("worktree checkout = %q, %v; want the committed bytes", data, err)
262 }
263 f.write("new.txt", "new\n")
264 f.mustRun("add", "-A")
265 cmd := Command(f.ctx, f.dir, "hash-object", "--stdin-paths")
266 cmd.Stdin = strings.NewReader("f.txt\n")
267 if out, err := cmd.CombinedOutput(); err != nil {
268 t.Fatalf("hash-object: %v: %s", err, out)
269 }
270 f.mustRun("commit", "--quiet", "-m", "add new")
271 _, _ = f.run("merge", "--no-edit", "side")
272 _, _ = f.run("merge", "--abort")
273 f.mustRun("update-ref", "refs/heads/probe", "HEAD")
274 f.assertNotExecuted()
275 }
276
277 // A driver name git's -c syntax cannot address must stop the invocation rather
278 // than let it run with that driver live.
279 func TestUnaddressableDriverNameFailsClosed(t *testing.T) {
280 f := newRepoFixture(t, "f.txt filter=a=b\n", map[string]string{"f.txt": "hello\n"})
281 f.appendConfig("config", "[filter \"a=b\"]\n\tclean = "+f.payload()+"\n")
282 f.makeStatDirty("f.txt", "hellx\n")
283
284 _, err := f.run("status", "--porcelain=v1")
285 if !errors.Is(err, ErrRepositoryDrivers) {
286 t.Fatalf("status err = %v, want ErrRepositoryDrivers", err)
287 }
288 // rev-parse converts no content, so it runs without the listing and still
289 // starts no driver.
290 f.mustRun("rev-parse", "HEAD")
291 f.assertNotExecuted()
292 }
293
294 func TestRepositoryWithoutDriversAddsNoOverrides(t *testing.T) {
295 f := newRepoFixture(t, "", map[string]string{"f.txt": "hello\n"})
296 cmd := Command(f.ctx, f.dir, "status", "--porcelain=v1")
297 for _, arg := range cmd.Args {
298 // The baseline pins merge.verifySignatures etc.; only per-driver
299 // overrides are what a driver-free repository must not add.
300 if strings.HasSuffix(arg, ".clean=") || strings.HasSuffix(arg, ".process=") || strings.HasSuffix(arg, ".driver=") {
301 t.Fatalf("args = %v, want no driver overrides for a driver-free repository", cmd.Args)
302 }
303 }
304 if cmd.Err != nil {
305 t.Fatalf("cmd.Err = %v", cmd.Err)
306 }
307 }
308
308 lines GO