| 1 | package gitcmd |
| 2 | |
| 3 | import ( |
| 4 | "bytes" |
| 5 | "context" |
| 6 | "errors" |
| 7 | "fmt" |
| 8 | "os/exec" |
| 9 | "slices" |
| 10 | "strings" |
| 11 | ) |
| 12 | |
| 13 | // ErrRepositoryDrivers reports that an invocation was not started because the |
| 14 | // repository's driver configuration could not be listed or neutralized. |
| 15 | var ErrRepositoryDrivers = errors.New("gitcmd: repository-defined git drivers cannot be neutralized") |
| 16 | |
| 17 | // repositoryScopes are the config scopes a repository's author controls; |
| 18 | // includes are reported under the scope of the file that includes them. |
| 19 | var repositoryScopes = []string{"local", "worktree"} |
| 20 | |
| 21 | // driverOverrides returns -c entries that empty every filter and merge driver |
| 22 | // defined at repository scope for the repository args will operate on, and |
| 23 | // turn off a repository-set submodule.recurse. The |
| 24 | // listing runs with the same leading global options, so -C and --git-dir |
| 25 | // resolve the same repository the invocation will. |
| 26 | func driverOverrides(ctx context.Context, dir string, repoEnv, args []string) ([]string, error) { |
| 27 | sub := subcommandIndex(args) |
| 28 | if sub < 0 || slices.Contains(noContentConversion, args[sub]) { |
| 29 | return nil, nil |
| 30 | } |
| 31 | query := []string{"config", "--name-only", "-z", "--get-regexp", `^(filter|merge)\.|^submodule\.recurse$`} |
| 32 | scoped := slices.Insert(slices.Clone(query), 1, "--show-scope") |
| 33 | out, err := listConfig(ctx, dir, repoEnv, args[:sub], scoped) |
| 34 | if exitCode(err) == 129 { |
| 35 | // git before 2.26 has no --show-scope; every definition is then |
| 36 | // treated as repository-authored. |
| 37 | out, err = listConfig(ctx, dir, repoEnv, args[:sub], query) |
| 38 | if err == nil { |
| 39 | return overridesFor(out, false) |
| 40 | } |
| 41 | } |
| 42 | if exitCode(err) == 1 { |
| 43 | return nil, nil |
| 44 | } |
| 45 | if err != nil { |
| 46 | return nil, fmt.Errorf("%w: %w", ErrRepositoryDrivers, err) |
| 47 | } |
| 48 | return overridesFor(out, true) |
| 49 | } |
| 50 | |
| 51 | // noContentConversion are subcommands that never pass content through a |
| 52 | // filter or merge driver and never honour submodule.recurse, so the listing |
| 53 | // (a second git process per call) could only return overrides they ignore. |
| 54 | var noContentConversion = []string{ |
| 55 | "version", "rev-parse", "symbolic-ref", "check-ref-format", "merge-base", "rev-list", |
| 56 | "show-ref", "for-each-ref", "update-ref", "write-tree", "commit-tree", |
| 57 | } |
| 58 | |
| 59 | func listConfig(ctx context.Context, dir string, repoEnv, globals, query []string) ([]byte, error) { |
| 60 | cmd := newCommand(ctx, Args(dir, nil, append(slices.Clone(globals), query...)...), repoEnv) |
| 61 | var stderr bytes.Buffer |
| 62 | cmd.Stderr = &stderr |
| 63 | out, err := cmd.Output() |
| 64 | if err != nil && exitCode(err) != 1 { |
| 65 | return nil, fmt.Errorf("%w: %s", err, strings.TrimSpace(stderr.String())) |
| 66 | } |
| 67 | return out, err |
| 68 | } |
| 69 | |
| 70 | func exitCode(err error) int { |
| 71 | var exit *exec.ExitError |
| 72 | if errors.As(err, &exit) { |
| 73 | return exit.ExitCode() |
| 74 | } |
| 75 | return 0 |
| 76 | } |
| 77 | |
| 78 | // overridesFor turns NUL-separated config key names — each preceded by its |
| 79 | // scope when scoped — into -c entries. Keys without a subsection (merge.ff) |
| 80 | // name no driver. |
| 81 | func overridesFor(out []byte, scoped bool) ([]string, error) { |
| 82 | fields := strings.Split(strings.TrimSuffix(string(out), "\x00"), "\x00") |
| 83 | var overrides []string |
| 84 | seen := map[string]bool{} |
| 85 | step := 1 |
| 86 | if scoped { |
| 87 | step = 2 |
| 88 | } |
| 89 | for i := 0; i+step-1 < len(fields); i += step { |
| 90 | key := fields[i+step-1] |
| 91 | if scoped && !slices.Contains(repositoryScopes, fields[i]) { |
| 92 | continue |
| 93 | } |
| 94 | if key == "submodule.recurse" { |
| 95 | // Only a repository-authored value is overridden, so a user's |
| 96 | // own global choice stays in force outside such repositories. |
| 97 | if !seen[key] { |
| 98 | seen[key] = true |
| 99 | overrides = append(overrides, "submodule.recurse=false") |
| 100 | } |
| 101 | continue |
| 102 | } |
| 103 | section, rest, _ := strings.Cut(key, ".") |
| 104 | dot := strings.LastIndexByte(rest, '.') |
| 105 | if dot <= 0 { |
| 106 | continue |
| 107 | } |
| 108 | name := rest[:dot] |
| 109 | if seen[section+"."+name] { |
| 110 | continue |
| 111 | } |
| 112 | seen[section+"."+name] = true |
| 113 | // -c splits at the first '=', so such a name cannot be addressed. |
| 114 | if strings.ContainsRune(name, '=') { |
| 115 | return nil, fmt.Errorf("%w: driver %q", ErrRepositoryDrivers, section+"."+name) |
| 116 | } |
| 117 | prefix := section + "." + name + "." |
| 118 | switch section { |
| 119 | case "filter": |
| 120 | // An empty command is no filter; required=false keeps the |
| 121 | // now-absent filter from failing the invocation. |
| 122 | overrides = append(overrides, prefix+"clean=", prefix+"smudge=", prefix+"process=", prefix+"required=false") |
| 123 | case "merge": |
| 124 | // An empty driver fails the merge of that path as a conflict. |
| 125 | overrides = append(overrides, prefix+"driver=") |
| 126 | } |
| 127 | } |
| 128 | return overrides, nil |
| 129 | } |
| 130 |