| 1 | package gitcmd |
| 2 | |
| 3 | import ( |
| 4 | "context" |
| 5 | "os" |
| 6 | "os/exec" |
| 7 | "path/filepath" |
| 8 | "runtime" |
| 9 | "slices" |
| 10 | "strings" |
| 11 | |
| 12 | "reasonix/internal/proc" |
| 13 | "reasonix/internal/secrets" |
| 14 | ) |
| 15 | |
| 16 | // baseConfig is the -c override set every invocation carries. |
| 17 | var baseConfig = []string{ |
| 18 | // An index refresh executes this as a command when the repository sets |
| 19 | // it. Empty rather than "false": git before 2.35.2 reads "false" as a |
| 20 | // hook name. |
| 21 | "core.fsmonitor=", |
| 22 | // Keeps a probe from starting git's background maintenance daemon. |
| 23 | "maintenance.auto=false", |
| 24 | "gc.auto=0", |
| 25 | // A hook path with no executables under it: checkout, merge and ref |
| 26 | // updates run no hook. |
| 27 | "core.hooksPath=" + os.DevNull, |
| 28 | // Otherwise log and show run gpg.program on every signed commit. |
| 29 | "log.showSignature=false", |
| 30 | "merge.verifySignatures=false", |
| 31 | // No inline submodule diff starts a git process that reads the |
| 32 | // submodule's own config. submodule.recurse is pinned per repository. |
| 33 | "diff.submodule=short", |
| 34 | // Discovery never lands on a bare repository by walking up: only a |
| 35 | // repository named with GIT_DIR (see Repo) is opened as bare. |
| 36 | "safe.bareRepository=explicit", |
| 37 | } |
| 38 | |
| 39 | // Args returns the full argument list for a git invocation: the hardening |
| 40 | // overrides, an optional -C directory, then the caller's arguments. extraConfig |
| 41 | // entries are "key=value" pairs appended after the baseline, so a call site can |
| 42 | // add its own preferences but cannot drop the baseline. Args does not consult |
| 43 | // the repository; Command adds the per-repository driver overrides. |
| 44 | func Args(dir string, extraConfig []string, args ...string) []string { |
| 45 | return argsFor(runtime.GOOS, dir, extraConfig, args...) |
| 46 | } |
| 47 | |
| 48 | func argsFor(goos, dir string, extraConfig []string, args ...string) []string { |
| 49 | var out []string |
| 50 | for _, cfg := range baseConfig { |
| 51 | out = append(out, "-c", cfg) |
| 52 | } |
| 53 | if goos == "windows" { |
| 54 | out = append(out, "-c", "core.longpaths=true") |
| 55 | } |
| 56 | for _, cfg := range extraConfig { |
| 57 | if cfg == "" { |
| 58 | continue |
| 59 | } |
| 60 | out = append(out, "-c", cfg) |
| 61 | } |
| 62 | if sub := subcommandIndex(args); sub >= 0 && slices.Contains(noRecurse, args[sub]) { |
| 63 | out = append(out, "-c", "submodule.recurse=false") |
| 64 | } |
| 65 | if dir != "" { |
| 66 | out = append(out, "-C", dir) |
| 67 | } |
| 68 | return append(out, hardenSubcommand(args)...) |
| 69 | } |
| 70 | |
| 71 | // noRecurse are the subcommands a user's own submodule.recurse=true would carry |
| 72 | // into each submodule, where that submodule's config applies. Host branch |
| 73 | // switches, merges and resets never update submodules. |
| 74 | var noRecurse = []string{"checkout", "switch", "restore", "reset", "merge", "read-tree"} |
| 75 | |
| 76 | // globalsWithValue are git's global options that take their value as the next |
| 77 | // argument when not written with '='. |
| 78 | var globalsWithValue = []string{"-C", "-c", "--git-dir", "--work-tree", "--namespace", "--config-env", "--super-prefix", "--exec-path"} |
| 79 | |
| 80 | // subcommandIndex returns the position of the subcommand in args, past any |
| 81 | // global options, or -1 when args names none. |
| 82 | func subcommandIndex(args []string) int { |
| 83 | for i := 0; i < len(args); i++ { |
| 84 | a := args[i] |
| 85 | if !strings.HasPrefix(a, "-") { |
| 86 | return i |
| 87 | } |
| 88 | if slices.Contains(globalsWithValue, a) { |
| 89 | i++ |
| 90 | } |
| 91 | } |
| 92 | return -1 |
| 93 | } |
| 94 | |
| 95 | // hardenSubcommand adds the flags that disable repository-configured programs |
| 96 | // for the subcommands that can invoke them. The flags go right after the |
| 97 | // subcommand, where git accepts them, and are only added when the caller has |
| 98 | // not already chosen that option. |
| 99 | func hardenSubcommand(args []string) []string { |
| 100 | sub := subcommandIndex(args) |
| 101 | if sub < 0 { |
| 102 | return args |
| 103 | } |
| 104 | rest := args[sub+1:] |
| 105 | var add []string |
| 106 | switch args[sub] { |
| 107 | case "diff", "log", "show": |
| 108 | for _, flag := range []string{"--no-ext-diff", "--no-textconv"} { |
| 109 | if !slices.Contains(rest, flag) { |
| 110 | add = append(add, flag) |
| 111 | } |
| 112 | } |
| 113 | } |
| 114 | switch args[sub] { |
| 115 | case "diff", "status": |
| 116 | if !slices.ContainsFunc(rest, func(a string) bool { return strings.HasPrefix(a, "--ignore-submodules") }) { |
| 117 | add = append(add, "--ignore-submodules=dirty") |
| 118 | } |
| 119 | } |
| 120 | if len(add) == 0 { |
| 121 | return args |
| 122 | } |
| 123 | out := slices.Clone(args[:sub+1]) |
| 124 | out = append(out, add...) |
| 125 | return append(out, rest...) |
| 126 | } |
| 127 | |
| 128 | // Command builds a hardened git command rooted at dir (empty runs in the |
| 129 | // process working directory). The environment drops credential variables so a |
| 130 | // git subprocess — and anything git itself starts — never inherits provider |
| 131 | // keys, and disables interactive prompts so a probe cannot block on one. |
| 132 | func Command(ctx context.Context, dir string, args ...string) *exec.Cmd { |
| 133 | return CommandWithConfig(ctx, dir, nil, args...) |
| 134 | } |
| 135 | |
| 136 | // CommandWithConfig is Command with additional "key=value" config overrides |
| 137 | // layered on top of the baseline. When the repository's drivers cannot be |
| 138 | // neutralized the command is returned unstartable: Run and Start report |
| 139 | // ErrRepositoryDrivers. |
| 140 | func CommandWithConfig(ctx context.Context, dir string, extraConfig []string, args ...string) *exec.Cmd { |
| 141 | return build(ctx, dir, nil, extraConfig, args) |
| 142 | } |
| 143 | |
| 144 | // build is every hardened invocation; repoEnv pins the repository (see Repo) |
| 145 | // for the driver listing and the command alike. |
| 146 | func build(ctx context.Context, dir string, repoEnv, extraConfig, args []string) *exec.Cmd { |
| 147 | if ctx == nil { |
| 148 | ctx = context.Background() |
| 149 | } |
| 150 | overrides, err := driverOverrides(ctx, dir, repoEnv, args) |
| 151 | cmd := newCommand(ctx, Args(dir, append(slices.Clone(extraConfig), overrides...), args...), repoEnv) |
| 152 | if err != nil { |
| 153 | cmd.Err = err |
| 154 | } |
| 155 | return cmd |
| 156 | } |
| 157 | |
| 158 | // Detached makes cmd run from a fresh empty directory above which git will not |
| 159 | // search, for commands that name their repository by URL: no repository's |
| 160 | // config reaches them. The returned cleanup removes the directory. |
| 161 | func Detached(cmd *exec.Cmd) (cleanup func(), err error) { |
| 162 | dir, err := os.MkdirTemp("", "reasonix-git-") |
| 163 | if err != nil { |
| 164 | return func() {}, err |
| 165 | } |
| 166 | env := cmd.Env |
| 167 | if env == nil { |
| 168 | env = os.Environ() |
| 169 | } |
| 170 | env = slices.DeleteFunc(slices.Clone(env), func(kv string) bool { |
| 171 | name, _, _ := strings.Cut(strings.ToUpper(kv), "=") |
| 172 | return name == "GIT_DIR" || name == "GIT_WORK_TREE" || name == "GIT_COMMON_DIR" || name == "GIT_CEILING_DIRECTORIES" |
| 173 | }) |
| 174 | cmd.Dir = dir |
| 175 | cmd.Env = append(env, "GIT_CEILING_DIRECTORIES="+filepath.Dir(dir)) |
| 176 | return func() { _ = os.RemoveAll(dir) }, nil |
| 177 | } |
| 178 | |
| 179 | func newCommand(ctx context.Context, args, repoEnv []string) *exec.Cmd { |
| 180 | cmd := proc.CommandContext(ctx, "git", args...) |
| 181 | cmd.Env = append(Env(), repoEnv...) |
| 182 | proc.HideWindow(cmd) |
| 183 | return cmd |
| 184 | } |
| 185 | |
| 186 | // Env is the environment a git subprocess runs with. GIT_EXTERNAL_DIFF and |
| 187 | // GIT_SSH_COMMAND are the user's own and stay: --no-ext-diff outranks the |
| 188 | // former, and the latter reaches only network commands, which run Detached. |
| 189 | func Env() []string { |
| 190 | return append(secrets.ProcessEnv(), |
| 191 | // Read-only probes must not take the index lock. |
| 192 | "GIT_OPTIONAL_LOCKS=0", |
| 193 | // Fail fast instead of blocking on a credential prompt for a terminal |
| 194 | // the TUI owns and the desktop app does not have. |
| 195 | "GIT_TERMINAL_PROMPT=0", |
| 196 | // A missing object is an error, never a fetch through the |
| 197 | // repository's remote configuration (git 2.44 and later). |
| 198 | "GIT_NO_LAZY_FETCH=1", |
| 199 | ) |
| 200 | } |
| 201 |