mirror of
https://github.com/esengine/DeepSeek-Reasonix.git
synced 2026-11-11 23:53:19 -10:00
Every git invocation the host makes goes through gitcmd, and each one must leave the programs a repository's own configuration names unexecuted: filter and merge drivers, textconv and external diff, fsmonitor, hooks, signature verifiers, submodule-boundary programs and lazy-fetch transport. Driver neutralization now covers every subcommand, not diff alone, and takes driver names from git itself rather than from a separate reading of .git/config. - Fixed keys are overridden with -c: core.fsmonitor (empty, not "false", which git before 2.35.2 reads as a hook name), core.hooksPath, log.showSignature, merge.verifySignatures, diff.submodule, gc.auto and maintenance.auto and safe.bareRepository=explicit; submodule.recurse=false for checkout, switch, restore, reset, merge and read-tree (host branch switches and merges never update submodules, whatever the user's global submodule.recurse), and elsewhere only where the repository's own config sets it. GIT_NO_LAZY_FETCH=1 stops a missing object becoming a fetch through the repository's remote (git 2.44+); a historical patch that fails for that reason returns gitcmd.ErrObjectNotLocal, decided by git's own --missing=print listing. - diff, log and show carry --no-ext-diff and --no-textconv; status and diff carry --ignore-submodules=dirty. The subcommand is found past global options, so a call passing -C inside args is hardened too. - Filter and merge drivers defined at local or worktree scope are listed by git itself before each invocation (includes followed) and overridden empty; user and system scope stay live; a name -c cannot address fails closed (ErrRepositoryDrivers). - worktree add uses --no-checkout and populates through gitcmd inside the new worktree, so a conditional include keyed on the new gitdir or branch is seen by the driver listing there rather than run by the add's checkout. The reset takes the worktree-mutation timeout, and a failed reset removes both the worktree and the branch the add created. worktree remove uses --force after the existing hardened clean-tree check, so git does not run status inside the linked worktree with its config. - Launcher ls-remote runs Detached with gitcmd's environment (only PATH is taken from the launcher's declared env), requires an https://, ssh:// or git:// remote (case-insensitive; http:// and local paths are refused), and passes the remote and ref past -- so a leading-dash value is an operand. - worktree git calls with extra environment keep gitcmd's environment. - Repository identity is resolved once, when a session opens: boot runs gitcmd.Open (rev-parse --absolute-git-dir --git-common-dir --show-toplevel) before any command, the controller carries the resulting gitcmd.Repo, and a rebuild of the same workspace passes it on. Every later host call runs through the Repo with GIT_DIR, GIT_COMMON_DIR and GIT_WORK_TREE set, so a nested .git, a commondir file, a rewritten .git file or a damaged HEAD written afterwards cannot substitute another repository or its config; a damaged HEAD fails closed. Threaded through the TUI status line, cli review (resolved once before the review agent runs), the desktop changes panel, change detail, history, commit detail, branch list/checkout/create and tab branch label (the tab session's identity; a tab with no session yet resolves at display), worktree Inspect/Create/RollbackCreate, and the whole merge-back flow: the source and worktree identities are recorded in the merge metadata at creation (the worktree's resolved right after worktree add) in metadata.repo.json beside metadata.json, whose shape older builds decode strictly and so is unchanged; every inspect, auto-commit, merge and cleanup call runs against them, and a worktree without that file resolves them from the roots. InspectMerge finds the allocation root from the managed layout instead of asking git in the worktree. - Repo.Verify applies Open's test to a stored identity; the identities read from metadata.repo.json are verified before the merge flow uses them. - Auto-commit staging uses gitcmd Repo.StageAll: gitlinks present as directories and untracked nested repositories are excluded with :(exclude,literal) pathspecs, because add -A checks a submodule by running git inside it under its own config. A gitlink keeps the commit the index records; an untracked nested repository leaves the worktree unverifiable and the auto-commit refuses. The worktree state token no longer runs git inside a changed nested repository. - Open accepts only a git dir belonging to the work tree that holds the directory: the work tree's own .git (sharing no other common dir) or a linked worktree's admin dir under <common>/worktrees whose gitdir file names that work tree's .git. A .git file or core.worktree pointing at another checkout is ErrNotRepository. Submodule checkouts and --separate-git-dir repositories opened directly are therefore treated as not version-controlled. Regression tests use real git with payload scripts: the gitcmd config-key matrix (signatures, lazy fetch, submodule boundary), the worktree call sites (conditional includes on Create, linked-worktree config on RollbackCreate), the status readout, the launcher's non-URL remote rejection, identity pinning after a nested .git, a commondir file and a damaged HEAD (config origin and filter marker), Open refusing another checkout's git dir (.git file, core.worktree, foreign admin dir), StageAll with nested repositories named like pathspec magic or globs, metadata.json read by a strict older decoder, merge-back without the identity file, StageAll and merge-back auto-commit past a gitlink with its own filter, merge-back after the worktree's .git file is pointed at another git dir, the status line and desktop changes panel of a workspace that later gains its own .git, and a global submodule.recurse=true against host checkout and reset. The list-then-run race is left to a follow-up that denies sandboxed writes to .git config and hooks.
201 lines
6.9 KiB
Go
201 lines
6.9 KiB
Go
package gitcmd
|
|
|
|
import (
|
|
"context"
|
|
"os"
|
|
"os/exec"
|
|
"path/filepath"
|
|
"runtime"
|
|
"slices"
|
|
"strings"
|
|
|
|
"reasonix/internal/proc"
|
|
"reasonix/internal/secrets"
|
|
)
|
|
|
|
// baseConfig is the -c override set every invocation carries.
|
|
var baseConfig = []string{
|
|
// An index refresh executes this as a command when the repository sets
|
|
// it. Empty rather than "false": git before 2.35.2 reads "false" as a
|
|
// hook name.
|
|
"core.fsmonitor=",
|
|
// Keeps a probe from starting git's background maintenance daemon.
|
|
"maintenance.auto=false",
|
|
"gc.auto=0",
|
|
// A hook path with no executables under it: checkout, merge and ref
|
|
// updates run no hook.
|
|
"core.hooksPath=" + os.DevNull,
|
|
// Otherwise log and show run gpg.program on every signed commit.
|
|
"log.showSignature=false",
|
|
"merge.verifySignatures=false",
|
|
// No inline submodule diff starts a git process that reads the
|
|
// submodule's own config. submodule.recurse is pinned per repository.
|
|
"diff.submodule=short",
|
|
// Discovery never lands on a bare repository by walking up: only a
|
|
// repository named with GIT_DIR (see Repo) is opened as bare.
|
|
"safe.bareRepository=explicit",
|
|
}
|
|
|
|
// Args returns the full argument list for a git invocation: the hardening
|
|
// overrides, an optional -C directory, then the caller's arguments. extraConfig
|
|
// entries are "key=value" pairs appended after the baseline, so a call site can
|
|
// add its own preferences but cannot drop the baseline. Args does not consult
|
|
// the repository; Command adds the per-repository driver overrides.
|
|
func Args(dir string, extraConfig []string, args ...string) []string {
|
|
return argsFor(runtime.GOOS, dir, extraConfig, args...)
|
|
}
|
|
|
|
func argsFor(goos, dir string, extraConfig []string, args ...string) []string {
|
|
var out []string
|
|
for _, cfg := range baseConfig {
|
|
out = append(out, "-c", cfg)
|
|
}
|
|
if goos == "windows" {
|
|
out = append(out, "-c", "core.longpaths=true")
|
|
}
|
|
for _, cfg := range extraConfig {
|
|
if cfg == "" {
|
|
continue
|
|
}
|
|
out = append(out, "-c", cfg)
|
|
}
|
|
if sub := subcommandIndex(args); sub >= 0 && slices.Contains(noRecurse, args[sub]) {
|
|
out = append(out, "-c", "submodule.recurse=false")
|
|
}
|
|
if dir != "" {
|
|
out = append(out, "-C", dir)
|
|
}
|
|
return append(out, hardenSubcommand(args)...)
|
|
}
|
|
|
|
// noRecurse are the subcommands a user's own submodule.recurse=true would carry
|
|
// into each submodule, where that submodule's config applies. Host branch
|
|
// switches, merges and resets never update submodules.
|
|
var noRecurse = []string{"checkout", "switch", "restore", "reset", "merge", "read-tree"}
|
|
|
|
// globalsWithValue are git's global options that take their value as the next
|
|
// argument when not written with '='.
|
|
var globalsWithValue = []string{"-C", "-c", "--git-dir", "--work-tree", "--namespace", "--config-env", "--super-prefix", "--exec-path"}
|
|
|
|
// subcommandIndex returns the position of the subcommand in args, past any
|
|
// global options, or -1 when args names none.
|
|
func subcommandIndex(args []string) int {
|
|
for i := 0; i < len(args); i++ {
|
|
a := args[i]
|
|
if !strings.HasPrefix(a, "-") {
|
|
return i
|
|
}
|
|
if slices.Contains(globalsWithValue, a) {
|
|
i++
|
|
}
|
|
}
|
|
return -1
|
|
}
|
|
|
|
// hardenSubcommand adds the flags that disable repository-configured programs
|
|
// for the subcommands that can invoke them. The flags go right after the
|
|
// subcommand, where git accepts them, and are only added when the caller has
|
|
// not already chosen that option.
|
|
func hardenSubcommand(args []string) []string {
|
|
sub := subcommandIndex(args)
|
|
if sub < 0 {
|
|
return args
|
|
}
|
|
rest := args[sub+1:]
|
|
var add []string
|
|
switch args[sub] {
|
|
case "diff", "log", "show":
|
|
for _, flag := range []string{"--no-ext-diff", "--no-textconv"} {
|
|
if !slices.Contains(rest, flag) {
|
|
add = append(add, flag)
|
|
}
|
|
}
|
|
}
|
|
switch args[sub] {
|
|
case "diff", "status":
|
|
if !slices.ContainsFunc(rest, func(a string) bool { return strings.HasPrefix(a, "--ignore-submodules") }) {
|
|
add = append(add, "--ignore-submodules=dirty")
|
|
}
|
|
}
|
|
if len(add) == 0 {
|
|
return args
|
|
}
|
|
out := slices.Clone(args[:sub+1])
|
|
out = append(out, add...)
|
|
return append(out, rest...)
|
|
}
|
|
|
|
// Command builds a hardened git command rooted at dir (empty runs in the
|
|
// process working directory). The environment drops credential variables so a
|
|
// git subprocess — and anything git itself starts — never inherits provider
|
|
// keys, and disables interactive prompts so a probe cannot block on one.
|
|
func Command(ctx context.Context, dir string, args ...string) *exec.Cmd {
|
|
return CommandWithConfig(ctx, dir, nil, args...)
|
|
}
|
|
|
|
// CommandWithConfig is Command with additional "key=value" config overrides
|
|
// layered on top of the baseline. When the repository's drivers cannot be
|
|
// neutralized the command is returned unstartable: Run and Start report
|
|
// ErrRepositoryDrivers.
|
|
func CommandWithConfig(ctx context.Context, dir string, extraConfig []string, args ...string) *exec.Cmd {
|
|
return build(ctx, dir, nil, extraConfig, args)
|
|
}
|
|
|
|
// build is every hardened invocation; repoEnv pins the repository (see Repo)
|
|
// for the driver listing and the command alike.
|
|
func build(ctx context.Context, dir string, repoEnv, extraConfig, args []string) *exec.Cmd {
|
|
if ctx == nil {
|
|
ctx = context.Background()
|
|
}
|
|
overrides, err := driverOverrides(ctx, dir, repoEnv, args)
|
|
cmd := newCommand(ctx, Args(dir, append(slices.Clone(extraConfig), overrides...), args...), repoEnv)
|
|
if err != nil {
|
|
cmd.Err = err
|
|
}
|
|
return cmd
|
|
}
|
|
|
|
// Detached makes cmd run from a fresh empty directory above which git will not
|
|
// search, for commands that name their repository by URL: no repository's
|
|
// config reaches them. The returned cleanup removes the directory.
|
|
func Detached(cmd *exec.Cmd) (cleanup func(), err error) {
|
|
dir, err := os.MkdirTemp("", "reasonix-git-")
|
|
if err != nil {
|
|
return func() {}, err
|
|
}
|
|
env := cmd.Env
|
|
if env == nil {
|
|
env = os.Environ()
|
|
}
|
|
env = slices.DeleteFunc(slices.Clone(env), func(kv string) bool {
|
|
name, _, _ := strings.Cut(strings.ToUpper(kv), "=")
|
|
return name == "GIT_DIR" || name == "GIT_WORK_TREE" || name == "GIT_COMMON_DIR" || name == "GIT_CEILING_DIRECTORIES"
|
|
})
|
|
cmd.Dir = dir
|
|
cmd.Env = append(env, "GIT_CEILING_DIRECTORIES="+filepath.Dir(dir))
|
|
return func() { _ = os.RemoveAll(dir) }, nil
|
|
}
|
|
|
|
func newCommand(ctx context.Context, args, repoEnv []string) *exec.Cmd {
|
|
cmd := proc.CommandContext(ctx, "git", args...)
|
|
cmd.Env = append(Env(), repoEnv...)
|
|
proc.HideWindow(cmd)
|
|
return cmd
|
|
}
|
|
|
|
// Env is the environment a git subprocess runs with. GIT_EXTERNAL_DIFF and
|
|
// GIT_SSH_COMMAND are the user's own and stay: --no-ext-diff outranks the
|
|
// former, and the latter reaches only network commands, which run Detached.
|
|
func Env() []string {
|
|
return append(secrets.ProcessEnv(),
|
|
// Read-only probes must not take the index lock.
|
|
"GIT_OPTIONAL_LOCKS=0",
|
|
// Fail fast instead of blocking on a credential prompt for a terminal
|
|
// the TUI owns and the desktop app does not have.
|
|
"GIT_TERMINAL_PROMPT=0",
|
|
// A missing object is an error, never a fetch through the
|
|
// repository's remote configuration (git 2.44 and later).
|
|
"GIT_NO_LAZY_FETCH=1",
|
|
)
|
|
}
|