返回 DeepSeek-Reasonix
gitcmd.go
根目录 / internal / gitcmd / gitcmd.go
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
201 lines GO