返回 DeepSeek-Reasonix
gitcmd.go
根目录 / internal / gitcmd / gitcmd.go
1 // Package gitcmd builds the git invocations Reasonix runs on its own behalf:
2 // the status readout, workspace change probes, worktree management, and plugin
3 // source checkouts.
4 //
5 // Every one of those may point at a repository Reasonix did not create, and a
6 // repository's own .git/config is data authored by whoever produced the
7 // repository — not configuration the user chose. Several config keys name a
8 // command that git then executes during ordinary read-only work: an index
9 // refresh runs core.fsmonitor, a diff runs diff.external or a textconv driver,
10 // auto-maintenance spawns a background daemon. Command-line -c overrides win
11 // over repository config, and the corresponding --no-* flags win over both, so
12 // every invocation carries the same baseline.
13 //
14 // Centralizing that baseline is the point of this package. The same overrides
15 // used to be spelled out at each call site, which is exactly how three of the
16 // five sites ended up carrying them and two did not.
17 //
18 // Content filters (filter.<driver>.clean/process) are selected per driver name
19 // through .gitattributes, so diffs neutralize every driver defined in the
20 // repository's local .git/config instead (see filterNeutralizingConfig).
21 // include.path chains and core.sshCommand remain the user's own to vet.
22 package gitcmd
23
24 import (
25 "bufio"
26 "context"
27 "os"
28 "os/exec"
29 "path/filepath"
30 "runtime"
31 "slices"
32 "strings"
33
34 "reasonix/internal/proc"
35 "reasonix/internal/secrets"
36 )
37
38 // baseConfig is the -c override set every invocation carries.
39 var baseConfig = []string{
40 // An index refresh (status, diff, rev-parse --show-toplevel in a dirty
41 // tree) executes this as a command when the repository sets it.
42 "core.fsmonitor=false",
43 // Keeps a probe from starting git's background maintenance daemon.
44 "maintenance.auto=false",
45 }
46
47 // Args returns the full argument list for a git invocation: the hardening
48 // overrides, an optional -C directory, then the caller's arguments. extraConfig
49 // entries are "key=value" pairs appended after the baseline, so a call site can
50 // add its own preferences but cannot drop the baseline.
51 func Args(dir string, extraConfig []string, args ...string) []string {
52 return argsFor(runtime.GOOS, dir, extraConfig, args...)
53 }
54
55 func argsFor(goos, dir string, extraConfig []string, args ...string) []string {
56 // No capacity hint: these lists are a handful of entries, and computing one
57 // from the input lengths buys nothing measurable.
58 var out []string
59 for _, cfg := range baseConfig {
60 out = append(out, "-c", cfg)
61 }
62 if goos == "windows" {
63 out = append(out, "-c", "core.longpaths=true")
64 }
65 for _, cfg := range extraConfig {
66 if cfg == "" {
67 continue
68 }
69 out = append(out, "-c", cfg)
70 }
71 for _, cfg := range filterNeutralizingConfig(dir, args) {
72 out = append(out, "-c", cfg)
73 }
74 if dir != "" {
75 out = append(out, "-C", dir)
76 }
77 return append(out, hardenSubcommand(args)...)
78 }
79
80 // filterNeutralizingConfig returns -c overrides that blank every filter driver
81 // command the repository's local .git/config defines, but only when args runs a
82 // diff — the one gitcmd subcommand that invokes clean filters on working-tree
83 // content. A diff compares the file's raw bytes, so an emptied filter is the
84 // correct rendering, not a degraded one. Git prefers a long-running process
85 // filter over clean when one is configured, so both command forms are emptied;
86 // required is forced off so a disabled required filter does not fail the diff.
87 func filterNeutralizingConfig(dir string, args []string) []string {
88 sub, cDir := gitSubcommand(args)
89 if sub != "diff" {
90 return nil
91 }
92 if cDir != "" {
93 dir = cDir
94 }
95 drivers := localFilterDrivers(dir)
96 if len(drivers) == 0 {
97 return nil
98 }
99 out := make([]string, 0, 3*len(drivers))
100 for _, name := range drivers {
101 out = append(out,
102 "filter."+name+".clean=",
103 "filter."+name+".process=",
104 "filter."+name+".required=false",
105 )
106 }
107 return out
108 }
109
110 // gitSubcommand finds the first non-global argument — the subcommand — and the
111 // directory named by a leading -C, if the caller put it inside args instead of
112 // the dir parameter. Global options before the subcommand are limited to the
113 // forms gitcmd's call sites use (-c v, -C d, --opt=v, --opt d); anything else
114 // still terminates the scan at the first bare word.
115 func gitSubcommand(args []string) (sub, cDir string) {
116 for i := 0; i < len(args); i++ {
117 a := args[i]
118 switch {
119 case a == "-C":
120 if i+1 < len(args) {
121 cDir = args[i+1]
122 i++
123 }
124 case strings.HasPrefix(a, "-C"):
125 cDir = strings.TrimPrefix(a, "-C")
126 case a == "-c":
127 i++ // skip the key=value that follows
128 case strings.HasPrefix(a, "-"):
129 // Any other global flag; --flag=value and bare flags alike carry
130 // no value we track. The next bare word ends global options.
131 default:
132 return a, cDir
133 }
134 }
135 return "", cDir
136 }
137
138 // localFilterDrivers lists the filter driver names defined by sections of the
139 // repository-local git config under dir. Only [filter "<name>"] sections are
140 // collected: the driver's command lives in the config, and the config is the
141 // part of a distributed repository its author controls. A missing or unreadable
142 // config yields no drivers (nothing to neutralize). User and system config are
143 // deliberately not read — those are the user's own choices.
144 func localFilterDrivers(dir string) []string {
145 var drivers []string
146 for _, cfgPath := range localGitConfigPaths(dir) {
147 f, err := os.Open(cfgPath)
148 if err != nil {
149 continue
150 }
151 scanner := bufio.NewScanner(f)
152 for scanner.Scan() {
153 line := strings.TrimSpace(scanner.Text())
154 if len(line) < 2 || line[0] != '[' || line[len(line)-1] != ']' {
155 continue
156 }
157 section := strings.TrimSpace(line[1 : len(line)-1])
158 i := strings.IndexAny(section, " \t")
159 if i < 0 || !strings.EqualFold(section[:i], "filter") {
160 continue
161 }
162 name := strings.Trim(strings.TrimSpace(section[i:]), `"`)
163 if name == "" || slices.Contains(drivers, name) {
164 continue
165 }
166 drivers = append(drivers, name)
167 }
168 _ = f.Close()
169 }
170 return drivers
171 }
172
173 // localGitConfigPaths resolves the repository-local configs for a working tree.
174 // Linked worktrees inherit <commondir>/config and may add
175 // <gitdir>/config.worktree when extensions.worktreeConfig is enabled.
176 func localGitConfigPaths(dir string) []string {
177 if dir == "" {
178 dir = "."
179 }
180 dotGit := filepath.Join(dir, ".git")
181 info, err := os.Stat(dotGit)
182 if err != nil {
183 return nil
184 }
185 gitdir := dotGit
186 if !info.IsDir() {
187 data, readErr := os.ReadFile(dotGit)
188 if readErr != nil {
189 return nil
190 }
191 line := strings.TrimSpace(string(data))
192 rest, ok := strings.CutPrefix(line, "gitdir:")
193 if !ok {
194 return nil
195 }
196 gitdir = strings.TrimSpace(rest)
197 if !filepath.IsAbs(gitdir) {
198 gitdir = filepath.Join(dir, gitdir)
199 }
200 }
201 gitdir = filepath.Clean(gitdir)
202 commonDir := gitdir
203 if data, readErr := os.ReadFile(filepath.Join(gitdir, "commondir")); readErr == nil {
204 commonDir = strings.TrimSpace(string(data))
205 if !filepath.IsAbs(commonDir) {
206 commonDir = filepath.Join(gitdir, commonDir)
207 }
208 commonDir = filepath.Clean(commonDir)
209 }
210 paths := []string{filepath.Join(commonDir, "config")}
211 worktreeConfig := filepath.Join(gitdir, "config.worktree")
212 if worktreeConfig != paths[0] {
213 paths = append(paths, worktreeConfig)
214 }
215 return paths
216 }
217
218 // hardenSubcommand adds the flags that disable repository-configured programs
219 // for the subcommands that can invoke them. The flags go after the subcommand
220 // name, where git accepts them, and are only added when absent so an explicit
221 // caller flag is never duplicated.
222 func hardenSubcommand(args []string) []string {
223 if len(args) == 0 || args[0] != "diff" {
224 return args
225 }
226 out := []string{args[0]}
227 for _, flag := range []string{"--no-ext-diff", "--no-textconv"} {
228 if !slices.Contains(args, flag) {
229 out = append(out, flag)
230 }
231 }
232 return append(out, args[1:]...)
233 }
234
235 // Command builds a hardened git command rooted at dir (empty runs in the
236 // process working directory). The environment drops credential variables so a
237 // git subprocess — and anything git itself starts — never inherits provider
238 // keys, and disables interactive prompts so a probe cannot block on one.
239 func Command(ctx context.Context, dir string, args ...string) *exec.Cmd {
240 return CommandWithConfig(ctx, dir, nil, args...)
241 }
242
243 // CommandWithConfig is Command with additional "key=value" config overrides
244 // layered on top of the baseline.
245 func CommandWithConfig(ctx context.Context, dir string, extraConfig []string, args ...string) *exec.Cmd {
246 if ctx == nil {
247 ctx = context.Background()
248 }
249 cmd := proc.CommandContext(ctx, "git", Args(dir, extraConfig, args...)...)
250 cmd.Env = Env()
251 proc.HideWindow(cmd)
252 return cmd
253 }
254
255 // Env is the environment a git subprocess runs with. GIT_EXTERNAL_DIFF is
256 // covered by --no-ext-diff on diff invocations, which outranks both the config
257 // key and the environment variable. GIT_SSH_COMMAND is deliberately left
258 // alone: it governs the ssh network transport (fetch/ls-remote/push), not diff
259 // rendering, and clearing it would break legitimate ssh remotes; repository
260 // config that sets core.sshCommand is a plugin-trust concern, not one this
261 // diff-oriented baseline can address (see the package residual note).
262 func Env() []string {
263 return append(secrets.ProcessEnv(),
264 // Read-only probes must not take the index lock.
265 "GIT_OPTIONAL_LOCKS=0",
266 // Fail fast instead of blocking on a credential prompt for a terminal
267 // the TUI owns and the desktop app does not have.
268 "GIT_TERMINAL_PROMPT=0",
269 )
270 }
271
271 lines GO