Files
Charan Rathore 60b301a9cd edit: a dotenv key set twice reads and writes its last line, as dotenv does (#832)
GetEnvFile returned the first line for a key and SetEnvFile replaced the first one. Gemini CLI reads ~/.gemini/.env with dotenv.parse, where a later line overrides an earlier one, so in a file with GEMINI_API_KEY twice the Gemini CLI uses the last, while magpie read and wrote the first. Wiring Gemini CLI to the gateway then changed a line the CLI ignored, and the check for whether it was wired read a value the CLI did not use.

GetEnvFile now returns the last line for the key and setLine replaces the last matching line (setLine is only used by SetEnvFile). A key that is set once behaves as before.

TestGetEnvFileLastDuplicateWins and TestSetEnvFileReplacesLastDuplicate fail on the old code. go vet and go test ./internal/edit ./internal/agent pass.
2026-10-05 07:02:42 +08:00

124 lines
2.9 KiB
Go

package edit
import (
"fmt"
"regexp"
"strconv"
"strings"
)
// TOML and top-level YAML entries are located by their parsers (toml.go and
// yaml_top.go), then edited without reformatting the rest of the file.
var (
// tomlTable and tomlKV only read a TOML file its parser refuses; see
// GetTOMLTop.
tomlTable = regexp.MustCompile(`^\s*\[`)
tomlKV = regexp.MustCompile(`^\s*([A-Za-z0-9_.-]+|"[^"]*")\s*=\s*(.*?)\s*$`)
)
// setLine replaces the line whose key matches, or inserts newLine after the
// last key line in the header section (before the first line matching stop).
func setLine(lines []string, key, newLine string, stop *regexp.Regexp, keyOf func(string) (string, bool)) []string {
lastKey, same := -1, -1
for i, line := range lines {
if stop != nil && stop.MatchString(line) {
break
}
k, ok := keyOf(line)
if !ok {
continue
}
if k == key {
same = i
}
lastKey = i
}
if same >= 0 {
// The last line for a key that is set twice is the one a reader uses.
lines[same] = newLine
return lines
}
at := lastKey + 1
out := make([]string, 0, len(lines)+1)
out = append(out, lines[:at]...)
out = append(out, newLine)
out = append(out, lines[at:]...)
return out
}
// unquote strips a leading quoted scalar (and anything after it, such as an
// inline comment); ok is false when v does not start with a quote.
func unquote(v string) (string, bool) {
if v == "" || (v[0] != '"' && v[0] != '\'') {
return "", false
}
q := v[0]
for i := 1; i < len(v); i++ {
if q == '"' && v[i] == '\\' {
i++
continue
}
if v[i] == q {
if q == '"' {
if s, err := strconv.Unquote(v[:i+1]); err == nil {
return s, true
}
}
return v[1:i], true
}
}
return v[1:], true
}
func tomlValue(v string) string {
if s, ok := unquote(v); ok {
return s
}
if i := strings.Index(v, "#"); i >= 0 {
v = strings.TrimSpace(v[:i])
}
return v
}
func toString(v any) string {
switch x := v.(type) {
case string:
return x
case bool:
return strconv.FormatBool(x)
case int:
return strconv.Itoa(x)
}
return fmt.Sprint(v)
}
// splitLines splits on '\n' but keeps a trailing newline as an empty final
// element so joinLines can restore the file exactly.
func splitLines(s string) []string {
if s == "" {
return []string{""}
}
return strings.Split(s, "\n")
}
// joinLinesLike is joinLines for lines edited out of orig: when orig used
// \r\n for every line break, the lines added or replaced get \r\n too, so the
// file never ends up with mixed endings. Untouched lines already carry their
// \r, and a file that mixed endings is left as it was.
func joinLinesLike(lines []string, orig string) string {
s := joinLines(lines)
if n := strings.Count(orig, "\n"); n > 0 && strings.Count(orig, "\r\n") == n {
s = strings.ReplaceAll(strings.ReplaceAll(s, "\r\n", "\n"), "\n", "\r\n")
}
return s
}
func joinLines(lines []string) string {
s := strings.Join(lines, "\n")
if !strings.HasSuffix(s, "\n") {
s += "\n"
}
return s
}