| 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 |