| 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 | // Known residual: content filters (filter.<driver>.clean/smudge) and textconv |
| 19 | // drivers are selected per driver name through .gitattributes, so they cannot |
| 20 | // be disabled by a fixed key list the way the settings above can. Diff-side |
| 21 | // drivers are covered by --no-ext-diff/--no-textconv; checkout/status-side |
| 22 | // clean filters are not, and would need a different mechanism. |
| 23 | package gitcmd |
| 24 | |
| 25 | import ( |
| 26 | "context" |
| 27 | "os/exec" |
| 28 | "runtime" |
| 29 | "slices" |
| 30 | |
| 31 | "reasonix/internal/proc" |
| 32 | "reasonix/internal/secrets" |
| 33 | ) |
| 34 | |
| 35 | // baseConfig is the -c override set every invocation carries. |
| 36 | var baseConfig = []string{ |
| 37 | // An index refresh (status, diff, rev-parse --show-toplevel in a dirty |
| 38 | // tree) executes this as a command when the repository sets it. |
| 39 | "core.fsmonitor=false", |
| 40 | // Keeps a probe from starting git's background maintenance daemon. |
| 41 | "maintenance.auto=false", |
| 42 | } |
| 43 | |
| 44 | // Args returns the full argument list for a git invocation: the hardening |
| 45 | // overrides, an optional -C directory, then the caller's arguments. extraConfig |
| 46 | // entries are "key=value" pairs appended after the baseline, so a call site can |
| 47 | // add its own preferences but cannot drop the baseline. |
| 48 | func Args(dir string, extraConfig []string, args ...string) []string { |
| 49 | return argsFor(runtime.GOOS, dir, extraConfig, args...) |
| 50 | } |
| 51 | |
| 52 | func argsFor(goos, dir string, extraConfig []string, args ...string) []string { |
| 53 | // No capacity hint: these lists are a handful of entries, and computing one |
| 54 | // from the input lengths buys nothing measurable. |
| 55 | var out []string |
| 56 | for _, cfg := range baseConfig { |
| 57 | out = append(out, "-c", cfg) |
| 58 | } |
| 59 | if goos == "windows" { |
| 60 | out = append(out, "-c", "core.longpaths=true") |
| 61 | } |
| 62 | for _, cfg := range extraConfig { |
| 63 | if cfg == "" { |
| 64 | continue |
| 65 | } |
| 66 | out = append(out, "-c", cfg) |
| 67 | } |
| 68 | if dir != "" { |
| 69 | out = append(out, "-C", dir) |
| 70 | } |
| 71 | return append(out, hardenSubcommand(args)...) |
| 72 | } |
| 73 | |
| 74 | // hardenSubcommand adds the flags that disable repository-configured programs |
| 75 | // for the subcommands that can invoke them. The flags go after the subcommand |
| 76 | // name, where git accepts them, and are only added when absent so an explicit |
| 77 | // caller flag is never duplicated. |
| 78 | func hardenSubcommand(args []string) []string { |
| 79 | if len(args) == 0 || args[0] != "diff" { |
| 80 | return args |
| 81 | } |
| 82 | out := []string{args[0]} |
| 83 | for _, flag := range []string{"--no-ext-diff", "--no-textconv"} { |
| 84 | if !slices.Contains(args, flag) { |
| 85 | out = append(out, flag) |
| 86 | } |
| 87 | } |
| 88 | return append(out, args[1:]...) |
| 89 | } |
| 90 | |
| 91 | // Command builds a hardened git command rooted at dir (empty runs in the |
| 92 | // process working directory). The environment drops credential variables so a |
| 93 | // git subprocess — and anything git itself starts — never inherits provider |
| 94 | // keys, and disables interactive prompts so a probe cannot block on one. |
| 95 | func Command(ctx context.Context, dir string, args ...string) *exec.Cmd { |
| 96 | return CommandWithConfig(ctx, dir, nil, args...) |
| 97 | } |
| 98 | |
| 99 | // CommandWithConfig is Command with additional "key=value" config overrides |
| 100 | // layered on top of the baseline. |
| 101 | func CommandWithConfig(ctx context.Context, dir string, extraConfig []string, args ...string) *exec.Cmd { |
| 102 | if ctx == nil { |
| 103 | ctx = context.Background() |
| 104 | } |
| 105 | cmd := exec.CommandContext(ctx, "git", Args(dir, extraConfig, args...)...) |
| 106 | cmd.Env = Env() |
| 107 | proc.HideWindow(cmd) |
| 108 | return cmd |
| 109 | } |
| 110 | |
| 111 | // Env is the environment a git subprocess runs with. GIT_EXTERNAL_DIFF and |
| 112 | // GIT_SSH_COMMAND are deliberately left alone: an empty value is a present |
| 113 | // value to git, so clearing them would break legitimate ssh remotes rather than |
| 114 | // harden anything, and --no-ext-diff already outranks both the config key and |
| 115 | // the environment variable. |
| 116 | func Env() []string { |
| 117 | return append(secrets.ProcessEnv(), |
| 118 | // Read-only probes must not take the index lock. |
| 119 | "GIT_OPTIONAL_LOCKS=0", |
| 120 | // Fail fast instead of blocking on a credential prompt for a terminal |
| 121 | // the TUI owns and the desktop app does not have. |
| 122 | "GIT_TERMINAL_PROMPT=0", |
| 123 | ) |
| 124 | } |
| 125 |