fix(memory): preserve index when reads fail

This commit is contained in:
xiexian committed 2026-09-26 13:12:33 +08:00
1 parent 7aff0b79ab
commit 2eb878989e
3 files changed
+131 -25

No files matched your search

+40 -22
View File
@@ -221,12 +221,16 @@ func (s Store) archiveLocked(name string) (string, error) {
if dir == "" {
continue
}
lines, contains, err := indexLinesExceptIn(dir, name)
if err != nil {
return "", err
}
p, err := archiveInDir(dir, name)
if err != nil {
return "", err
}
if p != "" || indexContainsIn(dir, name) {
if err := flushIndexIn(dir, indexLinesExceptIn(dir, name)); err != nil {
if p != "" || contains {
if err := flushIndexIn(dir, lines); err != nil {
return "", err
}
}
@@ -238,12 +242,16 @@ func (s Store) archiveLocked(name string) (string, error) {
}
func archiveMemoryInDir(dir, name string) (string, error) {
lines, contains, err := indexLinesExceptIn(dir, name)
if err != nil {
return "", err
}
path, err := archiveInDir(dir, name)
if err != nil {
return "", err
}
if path != "" || indexContainsIn(dir, name) {
if err := flushIndexIn(dir, indexLinesExceptIn(dir, name)); err != nil {
if path != "" || contains {
if err := flushIndexIn(dir, lines); err != nil {
return "", err
}
}
@@ -373,30 +381,34 @@ func repairOwnerWrite(root *os.Root, path string, dir bool) {
// MEMORY.md.
var indexLineRe = regexp.MustCompile(`(?m)^\s*-\s\[.+?\]\(([^)]+)\.md\)\s*—\s.*$`)
// indexLinesExceptIn returns the managed MEMORY.md lines keyed by filename stem
// in the given directory, dropping the entry for name (a missing index → empty map).
func indexLinesExceptIn(dir, name string) map[string]string {
existing, _ := fileencoding.ReadFileUTF8(filepath.Join(dir, indexFile))
keep := map[string]string{}
for line := range strings.SplitSeq(string(existing), "\n") {
if mt := indexLineRe.FindStringSubmatch(line); mt != nil && mt[1] != name {
keep[mt[1]] = strings.TrimRight(line, "\r")
}
// readIndexIn treats a missing index as empty, but does not hide other read errors.
func readIndexIn(dir string) ([]byte, error) {
existing, err := fileencoding.ReadFileUTF8(filepath.Join(dir, indexFile))
if os.IsNotExist(err) {
return nil, nil
}
return keep
return existing, err
}
func indexContainsIn(dir, name string) bool {
existing, err := fileencoding.ReadFileUTF8(filepath.Join(dir, indexFile))
// indexLinesExceptIn returns the managed MEMORY.md lines keyed by filename stem,
// dropping the entry for name and reporting whether it was present.
func indexLinesExceptIn(dir, name string) (map[string]string, bool, error) {
existing, err := readIndexIn(dir)
if err != nil {
return false
return nil, false, err
}
keep := map[string]string{}
contains := false
for line := range strings.SplitSeq(string(existing), "\n") {
if mt := indexLineRe.FindStringSubmatch(line); mt != nil && mt[1] == name {
return true
if mt := indexLineRe.FindStringSubmatch(line); mt != nil {
if mt[1] == name {
contains = true
} else {
keep[mt[1]] = strings.TrimRight(line, "\r")
}
}
}
return false
return keep, contains, nil
}
// flushIndexIn rewrites MEMORY.md in the given directory from the managed lines,
@@ -404,7 +416,10 @@ func indexContainsIn(dir, name string) bool {
// new managed entries are appended in sorted order.
func flushIndexIn(dir string, lines map[string]string) error {
path := filepath.Join(dir, indexFile)
existing, _ := fileencoding.ReadFileUTF8(path)
existing, err := readIndexIn(dir)
if err != nil {
return err
}
processed := map[string]bool{}
var preserved strings.Builder
preservedEmpty := true
@@ -457,7 +472,10 @@ func flushIndexIn(dir string, lines map[string]string) error {
// reindexIn rewrites the MEMORY.md line for name in the given directory,
// preserving every other managed line.
func reindexIn(dir, name string, m Memory) error {
lines := indexLinesExceptIn(dir, name)
lines, _, err := indexLinesExceptIn(dir, name)
if err != nil {
return err
}
lines[name] = renderIndexLine(name, m)
return flushIndexIn(dir, lines)
}
+80
View File
@@ -412,6 +412,86 @@ func TestStoreArchiveFlushesStaleIndexWithoutFile(t *testing.T) {
}
}
func TestStoreSavePreservesUnreadableIndex(t *testing.T) {
s := Store{Dir: t.TempDir()}
if _, err := s.Save(Memory{Name: "alpha", Description: "keep", Type: TypeProject, Body: "body"}); err != nil {
t.Fatal(err)
}
indexPath := filepath.Join(s.Dir, indexFile)
before := mustReadString(t, indexPath)
if err := os.Chmod(indexPath, 0); err != nil {
t.Fatal(err)
}
t.Cleanup(func() { _ = os.Chmod(indexPath, 0o644) })
if _, err := os.ReadFile(indexPath); err == nil {
t.Skip("this user can read files without read permission")
}
if _, err := s.Save(Memory{Name: "beta", Description: "new", Type: TypeProject, Body: "body"}); err == nil {
t.Fatal("Save should report the index read error")
}
if err := os.Chmod(indexPath, 0o644); err != nil {
t.Fatal(err)
}
if got := mustReadString(t, indexPath); got != before {
t.Fatalf("Save overwrote an unreadable index:\n%s", got)
}
}
func TestStoreArchivePreservesUnreadableIndex(t *testing.T) {
s := Store{Dir: t.TempDir()}
for _, name := range []string{"alpha", "beta"} {
if _, err := s.Save(Memory{Name: name, Description: "keep", Type: TypeProject, Body: "body"}); err != nil {
t.Fatal(err)
}
}
indexPath := filepath.Join(s.Dir, indexFile)
before := mustReadString(t, indexPath)
if err := os.Chmod(indexPath, 0); err != nil {
t.Fatal(err)
}
t.Cleanup(func() { _ = os.Chmod(indexPath, 0o644) })
if _, err := os.ReadFile(indexPath); err == nil {
t.Skip("this user can read files without read permission")
}
if _, err := s.Archive("alpha"); err == nil {
t.Fatal("Archive should report the index read error")
}
if err := os.Chmod(indexPath, 0o644); err != nil {
t.Fatal(err)
}
if got := mustReadString(t, indexPath); got != before {
t.Fatalf("Archive overwrote an unreadable index:\n%s", got)
}
if _, err := os.Stat(filepath.Join(s.Dir, "alpha.md")); err != nil {
t.Fatalf("Archive moved the fact before checking the index: %v", err)
}
}
func TestFlushIndexPreservesUnreadableFile(t *testing.T) {
dir := t.TempDir()
indexPath := filepath.Join(dir, indexFile)
before := "# Memory\n\nHand-written note.\n"
if err := os.WriteFile(indexPath, []byte(before), 0o644); err != nil {
t.Fatal(err)
}
if err := os.Chmod(indexPath, 0); err != nil {
t.Fatal(err)
}
t.Cleanup(func() { _ = os.Chmod(indexPath, 0o644) })
if _, err := os.ReadFile(indexPath); err == nil {
t.Skip("this user can read files without read permission")
}
if err := flushIndexIn(dir, map[string]string{"beta": "- [beta](beta.md) — new"}); err == nil {
t.Fatal("flushIndexIn should report the index read error")
}
if err := os.Chmod(indexPath, 0o644); err != nil {
t.Fatal(err)
}
if got := mustReadString(t, indexPath); got != before {
t.Fatalf("flushIndexIn overwrote hand-written content:\n%s", got)
}
}
func mustReadString(t *testing.T, path string) string {
t.Helper()
b, err := os.ReadFile(path)
+11 -3
View File
@@ -254,11 +254,15 @@ func (s Store) SaveWithOptions(m Memory, opts SaveOptions) (SaveResult, error) {
return SaveResult{}, err
}
if exists && cleanMemoryPath(existingPath) != cleanMemoryPath(path) {
oldDir := filepath.Dir(existingPath)
lines, _, err := indexLinesExceptIn(oldDir, existing.Name)
if err != nil {
return SaveResult{}, err
}
if err := os.Remove(existingPath); err != nil && !os.IsNotExist(err) {
return SaveResult{}, err
}
oldDir := filepath.Dir(existingPath)
if err := flushIndexIn(oldDir, indexLinesExceptIn(oldDir, existing.Name)); err != nil {
if err := flushIndexIn(oldDir, lines); err != nil {
return SaveResult{}, err
}
}
@@ -274,10 +278,14 @@ func (s Store) SaveWithOptions(m Memory, opts SaveOptions) (SaveResult, error) {
continue
}
if duplicate, _, ok := s.findActiveInDir(otherDir, m.Name); ok && duplicate.ID != m.ID {
lines, _, err := indexLinesExceptIn(otherDir, duplicate.Name)
if err != nil {
return SaveResult{}, err
}
if _, err := archiveInDir(otherDir, duplicate.Name); err != nil {
return SaveResult{}, err
}
if err := flushIndexIn(otherDir, indexLinesExceptIn(otherDir, duplicate.Name)); err != nil {
if err := flushIndexIn(otherDir, lines); err != nil {
return SaveResult{}, err
}
}