mirror of
https://github.com/runbear-io/beardrive.git
synced 2026-08-25 08:08:08 +02:00
* feat(history): group agent runs and restore any version (BEA-6) BearDrive recorded everything and could restore nothing. Now every version of a file has a Restore button — in the hub's History view and as `bdrive restore` — and the changes one agent run made read as one card instead of N loose rows. Restore is a NEW put op pointing at the old blob: journals are never rewritten, so one-writer-per-journal holds and peers converge on the restore like any other edit. The hub reuses RemoteSource.Commit (the upload commit minus the upload); the CLI writes the bytes into the working folder and lets the ordinary cycle journal them, so the sync engine gains no new write path. Grouping is a pure frontend group-by on (note, device) over the existing /history response — no journal or API change. Known gap, stated in the UI and the docs: nothing in the hub writes a delete op yet, so a file a run *created* cannot be un-created. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> * fix(history): don't repeat a run's note on every row in its card UI pass on the real hub: inside a run card the note is the card's header, so printing it again on each row said the same thing N times. The header now carries the note (linkified, so an agent's session link still opens) and the collapse control is its own button rather than the whole header — the link could not live inside a button. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> --------- Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com>
136 lines
4.2 KiB
Go
136 lines
4.2 KiB
Go
package main
|
|
|
|
import (
|
|
"bytes"
|
|
"strings"
|
|
"testing"
|
|
"time"
|
|
|
|
"github.com/runbear-io/beardrive/internal/journal"
|
|
)
|
|
|
|
// ops builds a newest-first log like syncer.LogEntries returns.
|
|
func ops(specs ...[3]string) []journal.Op {
|
|
var out []journal.Op
|
|
base := time.Date(2026, 7, 28, 14, 0, 0, 0, time.UTC)
|
|
for i := len(specs) - 1; i >= 0; i-- { // specs are oldest-first; log is newest-first
|
|
s := specs[i]
|
|
op := journal.Op{Kind: s[0], Path: s[1], Blob: s[2], Time: base.Add(time.Duration(i) * time.Minute)}
|
|
if s[0] == journal.KindPut {
|
|
op.Size = 10
|
|
op.Lamport = int64(i + 1)
|
|
} else {
|
|
op.Lamport = int64(i + 1)
|
|
}
|
|
out = append(out, op)
|
|
}
|
|
return out
|
|
}
|
|
|
|
const (
|
|
v1 = "a3f9c1e2000000000000000000000000000000000000000000000000000000aa"
|
|
v2 = "b7d40000000000000000000000000000000000000000000000000000000000bb"
|
|
v3 = "a3f900000000000000000000000000000000000000000000000000000000cccc"
|
|
)
|
|
|
|
// The previous version is the last content that differs from what the file
|
|
// holds now — not simply "the op before this one", which is wrong as soon as
|
|
// a run wrote the same path twice.
|
|
func TestPickPreviousVersion(t *testing.T) {
|
|
log := ops(
|
|
[3]string{journal.KindPut, "f.md", v1},
|
|
[3]string{journal.KindPut, "f.md", v2},
|
|
[3]string{journal.KindPut, "f.md", v2}, // same content written twice
|
|
)
|
|
vs := versionsOf(log, "f.md")
|
|
got, err := pickVersion(vs, currentBlob(log, "f.md"), "")
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if got.Blob != v1 {
|
|
t.Fatalf("previous = %s, want v1 (%s)", got.Blob, v1)
|
|
}
|
|
}
|
|
|
|
// Latest op is a delete: the file has no current content, so "previous" is
|
|
// simply its last content — which is what makes restoring a deleted file work.
|
|
func TestPickPreviousAfterDelete(t *testing.T) {
|
|
log := ops(
|
|
[3]string{journal.KindPut, "f.md", v1},
|
|
[3]string{journal.KindPut, "f.md", v2},
|
|
[3]string{journal.KindDelete, "f.md", ""},
|
|
)
|
|
got, err := pickVersion(versionsOf(log, "f.md"), currentBlob(log, "f.md"), "")
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if got.Blob != v2 {
|
|
t.Fatalf("previous after delete = %s, want v2", got.Blob)
|
|
}
|
|
}
|
|
|
|
// The only version there has ever been is not restorable — say so instead of
|
|
// writing the same bytes back.
|
|
func TestPickPreviousWhenOnlyVersion(t *testing.T) {
|
|
log := ops([3]string{journal.KindPut, "f.md", v1})
|
|
if _, err := pickVersion(versionsOf(log, "f.md"), currentBlob(log, "f.md"), ""); err == nil {
|
|
t.Fatal("want an error when there is no earlier version")
|
|
}
|
|
}
|
|
|
|
func TestPickByShortSHA(t *testing.T) {
|
|
log := ops(
|
|
[3]string{journal.KindPut, "f.md", v1},
|
|
[3]string{journal.KindPut, "f.md", v3}, // shares the "a3f9" prefix with v1
|
|
[3]string{journal.KindPut, "f.md", v2},
|
|
)
|
|
vs, cur := versionsOf(log, "f.md"), currentBlob(log, "f.md")
|
|
|
|
got, err := pickVersion(vs, cur, "a3f9c1")
|
|
if err != nil || got.Blob != v1 {
|
|
t.Fatalf("unique prefix → %v, %v", got.Blob, err)
|
|
}
|
|
if _, err := pickVersion(vs, cur, "a3f9"); err == nil || !strings.Contains(err.Error(), "more characters") {
|
|
t.Fatalf("ambiguous prefix must refuse, got %v", err)
|
|
}
|
|
if _, err := pickVersion(vs, cur, "ffff"); err == nil {
|
|
t.Fatal("unknown prefix must error")
|
|
}
|
|
}
|
|
|
|
// LogEntries' path filter also matches directories and prefixes, so the
|
|
// command re-filters: restoring the wrong file would be the worst bug here.
|
|
func TestVersionsOfIsExactPath(t *testing.T) {
|
|
log := ops(
|
|
[3]string{journal.KindPut, "docs/f.md", v1},
|
|
[3]string{journal.KindPut, "docs/f.md.bak", v2},
|
|
[3]string{journal.KindPut, "docs", v3},
|
|
)
|
|
vs := versionsOf(log, "docs/f.md")
|
|
if len(vs) != 1 || vs[0].Blob != v1 {
|
|
t.Fatalf("versionsOf = %+v, want just docs/f.md", vs)
|
|
}
|
|
if len(versionsOf(log, "nope.md")) != 0 {
|
|
t.Fatal("a path with no history must yield no versions")
|
|
}
|
|
}
|
|
|
|
func TestPrintVersionsMarksCurrent(t *testing.T) {
|
|
log := ops(
|
|
[3]string{journal.KindPut, "f.md", v1},
|
|
[3]string{journal.KindPut, "f.md", v2},
|
|
)
|
|
var out bytes.Buffer
|
|
printVersions(&out, versionsOf(log, "f.md"), currentBlob(log, "f.md"))
|
|
lines := strings.Split(strings.TrimSpace(out.String()), "\n")
|
|
if len(lines) != 2 {
|
|
t.Fatalf("listing = %d lines, want 2:\n%s", len(lines), out.String())
|
|
}
|
|
if !strings.HasPrefix(lines[0], "* "+v2[:8]) {
|
|
t.Fatalf("current version not marked: %q", lines[0])
|
|
}
|
|
if !strings.HasPrefix(lines[1], " "+v1[:8]) {
|
|
t.Fatalf("older version line = %q", lines[1])
|
|
}
|
|
}
|