in internal/git/housekeeping/clean_stale_data.go [105:202]
func PruneEmptyConfigSections(ctx context.Context, repo *localrepo.Repo) (_ int, returnedErr error) {
repoPath, err := repo.Path(ctx)
if err != nil {
return 0, fmt.Errorf("getting repo path: %w", err)
}
configPath := filepath.Join(repoPath, "config")
// The gitconfig shouldn't ever be big given that we nowadays don't write any unbounded
// values into it anymore. Slurping it into memory should thus be fine.
configContents, err := os.ReadFile(configPath)
if err != nil {
return 0, fmt.Errorf("reading config: %w", err)
}
configLines := strings.SplitAfter(string(configContents), "\n")
if configLines[len(configLines)-1] == "" {
// Strip the last line if it's empty.
configLines = configLines[:len(configLines)-1]
}
skippedSections := 0
// We now filter out any empty sections. A section is empty if the next line is a section
// header as well, or if it is the last line in the gitconfig. This isn't quite the whole
// story given that a section can also be empty if it just ain't got any keys, but only
// comments or whitespace. But we only ever write the gitconfig programmatically, so we
// shouldn't typically see any such cases at all.
filteredLines := make([]string, 0, len(configLines))
for i := 0; i < len(configLines)-1; i++ {
// Skip if we have two consecutive section headers.
if isSectionHeader(configLines[i]) && isSectionHeader(configLines[i+1]) {
skippedSections++
continue
}
filteredLines = append(filteredLines, configLines[i])
}
// The final line is always stripped in case it is a section header.
if len(configLines) > 0 && !isSectionHeader(configLines[len(configLines)-1]) {
skippedSections++
filteredLines = append(filteredLines, configLines[len(configLines)-1])
}
// If we haven't filtered out anything then there is no need to update the target file.
if len(configLines) == len(filteredLines) {
return 0, nil
}
// Otherwise, we need to update the repository's configuration.
configWriter, err := safe.NewLockingFileWriter(configPath)
if err != nil {
return 0, fmt.Errorf("creating config configWriter: %w", err)
}
defer func() {
if err := configWriter.Close(); err != nil && returnedErr == nil {
returnedErr = fmt.Errorf("closing config writer: %w", err)
}
}()
for _, filteredLine := range filteredLines {
if _, err := configWriter.Write([]byte(filteredLine)); err != nil {
return 0, fmt.Errorf("writing filtered config: %w", err)
}
}
// This is a sanity check to assert that we really didn't change anything as seen by
// Git. We run `git config -l` on both old and new file and assert that they report
// the same config entries. Because empty sections are never reported we shouldn't
// see those, and as a result any difference in output is a difference we need to
// worry about.
var configOutputs []string
for _, path := range []string{configPath, configWriter.Path()} {
var configOutput bytes.Buffer
if err := repo.ExecAndWait(ctx, gitcmd.Command{
Name: "config",
Flags: []gitcmd.Option{
gitcmd.ValueFlag{Name: "-f", Value: path},
gitcmd.Flag{Name: "-l"},
},
}, gitcmd.WithStdout(&configOutput)); err != nil {
return 0, fmt.Errorf("listing config: %w", err)
}
configOutputs = append(configOutputs, configOutput.String())
}
if configOutputs[0] != configOutputs[1] {
return 0, fmt.Errorf("section pruning has caused config change")
}
// We don't use transactional voting but commit the file directly -- we have asserted that
// the change is idempotent anyway.
if err := configWriter.Lock(); err != nil {
return 0, fmt.Errorf("failed locking config: %w", err)
}
if err := configWriter.Commit(ctx); err != nil {
return 0, fmt.Errorf("failed committing pruned config: %w", err)
}
return skippedSections, nil
}