Skip to content

Commit 9aabee5

Browse files
committed
fix/gitindex: port selective config syncing onto current clone updates
Port the old idea from #635 onto current main so mirror updates stop rewriting every zoekt config value on every pass. The original patch shelled out to git config and never landed; moving the update path to go-git keeps it aligned with the rest of clone.go, makes it straightforward to detect real config changes, and lets CloneRepo return the repo path only when an existing clone actually needs reindexing. While touching the port, also cover the stale-metadata case the old PR still missed. Empty settings now remove old zoekt.* keys instead of leaving outdated values behind, and remote.origin.url still stays in sync for existing clones. Test Plan: go test ./gitindex
1 parent 034d5a3 commit 9aabee5

2 files changed

Lines changed: 255 additions & 7 deletions

File tree

gitindex/clone.go

Lines changed: 148 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -23,30 +23,167 @@ import (
2323
"os/exec"
2424
"path/filepath"
2525
"sort"
26+
"strings"
2627

2728
git "github.com/go-git/go-git/v5"
2829
"github.com/go-git/go-git/v5/config"
30+
formatconfig "github.com/go-git/go-git/v5/plumbing/format/config"
2931
)
3032

33+
type gitConfigPath struct {
34+
section string
35+
subsection string
36+
key string
37+
}
38+
39+
func parseGitConfigPath(key string) (gitConfigPath, error) {
40+
parts := strings.SplitN(key, ".", 3)
41+
switch len(parts) {
42+
case 2:
43+
return gitConfigPath{section: parts[0], key: parts[1]}, nil
44+
case 3:
45+
return gitConfigPath{section: parts[0], subsection: parts[1], key: parts[2]}, nil
46+
default:
47+
return gitConfigPath{}, fmt.Errorf("invalid git config key %q", key)
48+
}
49+
}
50+
51+
func lookupRawConfigOption(cfg *formatconfig.Config, path gitConfigPath) (string, bool) {
52+
if cfg == nil {
53+
return "", false
54+
}
55+
56+
for _, section := range cfg.Sections {
57+
if !section.IsName(path.section) {
58+
continue
59+
}
60+
61+
if path.subsection == "" {
62+
if !section.HasOption(path.key) {
63+
return "", false
64+
}
65+
return section.Option(path.key), true
66+
}
67+
68+
for _, subsection := range section.Subsections {
69+
if !subsection.IsName(path.subsection) {
70+
continue
71+
}
72+
if !subsection.HasOption(path.key) {
73+
return "", false
74+
}
75+
return subsection.Option(path.key), true
76+
}
77+
78+
return "", false
79+
}
80+
81+
return "", false
82+
}
83+
84+
func updateRawConfigOption(cfg *formatconfig.Config, path gitConfigPath, value string) bool {
85+
current, ok := lookupRawConfigOption(cfg, path)
86+
if value == "" {
87+
if !ok {
88+
return false
89+
}
90+
91+
if path.subsection == "" {
92+
cfg.Section(path.section).RemoveOption(path.key)
93+
} else {
94+
cfg.Section(path.section).Subsection(path.subsection).RemoveOption(path.key)
95+
}
96+
97+
return true
98+
}
99+
100+
if ok && current == value {
101+
return false
102+
}
103+
104+
if path.subsection == "" {
105+
cfg.Section(path.section).SetOption(path.key, value)
106+
} else {
107+
cfg.Section(path.section).Subsection(path.subsection).SetOption(path.key, value)
108+
}
109+
110+
return true
111+
}
112+
113+
func updateRemoteURL(cfg *config.Config, path gitConfigPath, value string) (bool, error) {
114+
if value == "" {
115+
return false, fmt.Errorf("remote URL for %q cannot be empty", path.subsection)
116+
}
117+
118+
remote := cfg.Remotes[path.subsection]
119+
if remote == nil {
120+
remote = &config.RemoteConfig{Name: path.subsection}
121+
cfg.Remotes[path.subsection] = remote
122+
}
123+
124+
current := ""
125+
if len(remote.URLs) > 0 {
126+
current = remote.URLs[0]
127+
}
128+
if current == value && len(remote.URLs) == 1 {
129+
return false, nil
130+
}
131+
132+
remote.URLs = []string{value}
133+
return true, nil
134+
}
135+
136+
func updateGitConfigOption(cfg *config.Config, key, value string) (bool, error) {
137+
path, err := parseGitConfigPath(key)
138+
if err != nil {
139+
return false, err
140+
}
141+
142+
if path.section == "remote" && path.subsection != "" && path.key == "url" {
143+
return updateRemoteURL(cfg, path, value)
144+
}
145+
146+
return updateRawConfigOption(cfg.Raw, path, value), nil
147+
}
148+
31149
// Updates the zoekt.* git config options after a repo is cloned.
32150
// Once a repo is cloned, we can no longer use the --config flag to update all
33151
// of it's zoekt.* settings at once. `git config` is limited to one option at once.
34-
func updateZoektGitConfig(repoDest string, settings map[string]string) error {
152+
func updateZoektGitConfig(repoDest string, settings map[string]string) (bool, error) {
153+
repo, err := git.PlainOpen(repoDest)
154+
if err != nil {
155+
return false, err
156+
}
157+
158+
cfg, err := repo.Config()
159+
if err != nil {
160+
return false, err
161+
}
162+
35163
var keys []string
36164
for k := range settings {
37165
keys = append(keys, k)
38166
}
39167
sort.Strings(keys)
40168

169+
var changed bool
41170
for _, k := range keys {
42-
if settings[k] != "" {
43-
if err := exec.Command("git", "-C", repoDest, "config", k, settings[k]).Run(); err != nil {
44-
return err
45-
}
171+
updated, err := updateGitConfigOption(cfg, k, settings[k])
172+
if err != nil {
173+
return false, err
46174
}
175+
changed = changed || updated
47176
}
48177

49-
return nil
178+
if !changed {
179+
return false, nil
180+
}
181+
182+
if err := repo.Storer.SetConfig(cfg); err != nil {
183+
return false, err
184+
}
185+
186+
return true, nil
50187
}
51188

52189
// CloneRepo clones one repository, adding the given config
@@ -64,9 +201,13 @@ func CloneRepo(destDir, name, cloneURL string, settings map[string]string) (stri
64201
// Repository exists, ensure settings are in sync including the clone URL
65202
settings := maps.Clone(settings)
66203
settings["remote.origin.url"] = cloneURL
67-
if err := updateZoektGitConfig(repoDest, settings); err != nil {
204+
hadUpdate, err := updateZoektGitConfig(repoDest, settings)
205+
if err != nil {
68206
return "", fmt.Errorf("failed to update repository settings: %w", err)
69207
}
208+
if hadUpdate {
209+
return repoDest, nil
210+
}
70211
return "", nil
71212
}
72213

gitindex/clone_test.go

Lines changed: 107 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -15,7 +15,11 @@
1515
package gitindex
1616

1717
import (
18+
"bytes"
19+
"errors"
1820
"os/exec"
21+
"path/filepath"
22+
"strings"
1923
"testing"
2024

2125
git "github.com/go-git/go-git/v5"
@@ -56,3 +60,106 @@ git clone orig/.git clone.git
5660
t.Fatalf("got %q want %q", got, want)
5761
}
5862
}
63+
64+
func gitConfigValue(t *testing.T, repoDir, key string) (string, bool) {
65+
t.Helper()
66+
67+
cmd := exec.Command("git", "-C", repoDir, "config", "--get", key)
68+
var stdout bytes.Buffer
69+
cmd.Stdout = &stdout
70+
71+
err := cmd.Run()
72+
if err == nil {
73+
return strings.TrimSuffix(stdout.String(), "\n"), true
74+
}
75+
76+
var exitErr *exec.ExitError
77+
if errors.As(err, &exitErr) && exitErr.ExitCode() == 1 {
78+
return "", false
79+
}
80+
81+
t.Fatalf("git config --get %s: %v", key, err)
82+
return "", false
83+
}
84+
85+
func TestCloneRepoReturnsDestinationWhenSettingsChange(t *testing.T) {
86+
root := t.TempDir()
87+
origin := filepath.Join(root, "origin.git")
88+
runScript(t, root, "git init --bare "+origin)
89+
90+
destRoot := filepath.Join(root, "repos")
91+
dest, err := CloneRepo(destRoot, "owner/repo", origin, map[string]string{
92+
"zoekt.name": "github.com/owner/repo",
93+
"zoekt.description": "initial",
94+
})
95+
if err != nil {
96+
t.Fatalf("CloneRepo initial clone: %v", err)
97+
}
98+
99+
repoDest := filepath.Join(destRoot, "owner", "repo.git")
100+
if dest != repoDest {
101+
t.Fatalf("got %q want %q", dest, repoDest)
102+
}
103+
104+
dest, err = CloneRepo(destRoot, "owner/repo", origin, map[string]string{
105+
"zoekt.name": "github.com/owner/repo",
106+
"zoekt.description": "initial",
107+
})
108+
if err != nil {
109+
t.Fatalf("CloneRepo no-op update: %v", err)
110+
}
111+
if dest != "" {
112+
t.Fatalf("got %q want empty destination for unchanged settings", dest)
113+
}
114+
115+
dest, err = CloneRepo(destRoot, "owner/repo", origin, map[string]string{
116+
"zoekt.name": "github.com/owner/repo",
117+
"zoekt.description": "updated",
118+
})
119+
if err != nil {
120+
t.Fatalf("CloneRepo changed settings: %v", err)
121+
}
122+
if dest != repoDest {
123+
t.Fatalf("got %q want %q when settings changed", dest, repoDest)
124+
}
125+
126+
if got, ok := gitConfigValue(t, repoDest, "zoekt.description"); !ok || got != "updated" {
127+
t.Fatalf("got zoekt.description=%q exists=%t want updated/true", got, ok)
128+
}
129+
}
130+
131+
func TestCloneRepoRemovesEmptyZoektSettingsAndUpdatesOriginURL(t *testing.T) {
132+
root := t.TempDir()
133+
originA := filepath.Join(root, "origin-a.git")
134+
originB := filepath.Join(root, "origin-b.git")
135+
runScript(t, root, "git init --bare "+originA)
136+
runScript(t, root, "git init --bare "+originB)
137+
138+
destRoot := filepath.Join(root, "repos")
139+
if _, err := CloneRepo(destRoot, "owner/repo", originA, map[string]string{
140+
"zoekt.name": "github.com/owner/repo",
141+
"zoekt.description": "present",
142+
}); err != nil {
143+
t.Fatalf("CloneRepo initial clone: %v", err)
144+
}
145+
146+
dest, err := CloneRepo(destRoot, "owner/repo", originB, map[string]string{
147+
"zoekt.name": "github.com/owner/repo",
148+
"zoekt.description": "",
149+
})
150+
if err != nil {
151+
t.Fatalf("CloneRepo update: %v", err)
152+
}
153+
154+
repoDest := filepath.Join(destRoot, "owner", "repo.git")
155+
if dest != repoDest {
156+
t.Fatalf("got %q want %q", dest, repoDest)
157+
}
158+
159+
if got, ok := gitConfigValue(t, repoDest, "zoekt.description"); ok {
160+
t.Fatalf("got stale zoekt.description=%q, want it removed", got)
161+
}
162+
if got, ok := gitConfigValue(t, repoDest, "remote.origin.url"); !ok || got != originB {
163+
t.Fatalf("got remote.origin.url=%q exists=%t want %q/true", got, ok, originB)
164+
}
165+
}

0 commit comments

Comments
 (0)