Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 5 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
@@ -1,6 +1,11 @@
# memory-bank-cli
CLI for installing, updating, validating, and diagnosing Memory Bank templates

`init` and `update` treat every tracked regular file below an upstream
`template/` directory as canonical payload. `template/memory-bank/**` installs
to `memory-bank/**`; every other path retains its repository-relative suffix.
Dotfiles and executable files are included, while symlinks are rejected.

## Publish managed changes upstream

From a downstream Git repository with a clean upstream checkout at `memory-bank/.repo`, preview the managed changes that can be proposed upstream:
Expand Down
2 changes: 1 addition & 1 deletion internal/cli/cli.go
Original file line number Diff line number Diff line change
Expand Up @@ -205,7 +205,7 @@ func runOwnership(arguments []string, command string, stdout, stderr io.Writer)
flags.PrintDefaults()
}
repoRootArgument := addRepoRootFlag(flags)
sourceRootArgument := flags.String("source", "", "clean template Git checkout containing exactly one payload root: template/memory-bank/, legacy memory-bank-template/, or legacy memory-bank/")
sourceRootArgument := flags.String("source", "", "clean template Git checkout containing template/ (legacy memory-bank-template/ or memory-bank/ is accepted for migration)")
templateVersion := flags.String("template-version", "", "human-readable template version")
sourceRef := flags.String("source-ref", "", "full commit SHA matching the source checkout HEAD")
dryRun := flags.Bool("dry-run", false, "print the complete mutation plan without applying it")
Expand Down
2 changes: 1 addition & 1 deletion internal/ownership/lock.go
Original file line number Diff line number Diff line change
Expand Up @@ -65,7 +65,7 @@ func readLockSnapshot(repo pinnedRepo) (Lock, bool, string, error) {
return Lock{}, false, "", fmt.Errorf("invalid last update in %s", LockFileName)
}
for filePath, file := range lock.Files {
if filePath == LockFileName || strings.Contains(filePath, "\\") || path.Clean(filePath) != filePath || !strings.HasPrefix(filePath, "memory-bank/") {
if filePath == LockFileName || path.IsAbs(filePath) || strings.Contains(filePath, "\\") || path.Clean(filePath) != filePath || strings.HasPrefix(filePath, "../") || filePath == "." {
return Lock{}, false, "", fmt.Errorf("invalid path %q in %s", filePath, LockFileName)
}
switch file.Ownership {
Expand Down
8 changes: 5 additions & 3 deletions internal/ownership/source.go
Original file line number Diff line number Diff line change
Expand Up @@ -18,7 +18,7 @@ type pinnedSource struct {
const (
legacySourcePayloadRoot = "memory-bank"
legacyTemplateSourcePayloadRoot = "memory-bank-template"
targetSourcePayloadRoot = "template/memory-bank"
targetSourcePayloadRoot = "template"
downstreamPayloadRoot = "memory-bank"
)

Expand Down Expand Up @@ -181,6 +181,8 @@ func sourcePayloadRoots() []string {
return []string{legacySourcePayloadRoot, legacyTemplateSourcePayloadRoot, targetSourcePayloadRoot}
}

// selectSourcePayloadRoot prefers the canonical template tree. The other
// roots are accepted only to migrate locks written by earlier CLI releases.
func selectSourcePayloadRoot(present []string) (string, error) {
legacy := make([]string, 0, 2)
for _, root := range present {
Expand All @@ -197,9 +199,9 @@ func selectSingleSourcePayloadRoot(present []string) (string, error) {
case 1:
return present[0], nil
case 0:
return "", errors.New("source has neither recognized payload root: memory-bank, memory-bank-template, or template/memory-bank")
return "", errors.New("source has neither recognized payload root: template, memory-bank-template, or memory-bank")
default:
return "", errors.New("source has multiple recognized payload roots: memory-bank, memory-bank-template, or template/memory-bank")
return "", errors.New("source has multiple recognized payload roots: template, memory-bank-template, or memory-bank")
}
}

Expand Down
80 changes: 76 additions & 4 deletions internal/ownership/source_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -103,7 +103,6 @@ func TestPinnedSourceSupportsOneRecognizedRootAndTranslatesToDownstream(t *testi
}{
{name: "legacy root", roots: []string{"memory-bank"}, wantSource: "memory-bank"},
{name: "legacy template root", roots: []string{"memory-bank-template"}, wantSource: "memory-bank-template"},
{name: "target root", roots: []string{"template/memory-bank"}, wantSource: "template/memory-bank"},
{name: "neither root", wantErr: "neither recognized payload root"},
{name: "multiple legacy roots", roots: []string{legacySourcePayloadRoot, legacyTemplateSourcePayloadRoot}, wantErr: "multiple recognized payload roots"},
} {
Expand Down Expand Up @@ -151,7 +150,11 @@ func TestTargetSourcePayloadRootTakesPrecedenceOverLegacyRoots(t *testing.T) {
t.Run(test.name, func(t *testing.T) {
source := t.TempDir()
for _, root := range test.roots {
write(t, source, root+"/dna/rule.md", root+"\n")
path := root + "/dna/rule.md"
if root == targetSourcePayloadRoot {
path = root + "/memory-bank/dna/rule.md"
}
write(t, source, path, root+"\n")
}
write(t, source, legacySourcePayloadRoot+"/.lock", "project-local\n")
commit := commitTestSource(t, source)
Expand All @@ -168,7 +171,7 @@ func TestTargetSourcePayloadRootTakesPrecedenceOverLegacyRoots(t *testing.T) {

func TestPinnedSourceTargetRootWinsOverLockedProjectLocalRootForInitAndUpdate(t *testing.T) {
source, repo := t.TempDir(), t.TempDir()
write(t, source, targetSourcePayloadRoot+"/dna/rule.md", "target v1\n")
write(t, source, targetSourcePayloadRoot+"/memory-bank/dna/rule.md", "target v1\n")
write(t, source, legacySourcePayloadRoot+"/dna/rule.md", "project local\n")
write(t, source, legacySourcePayloadRoot+"/.lock", "project-local lock\n")
write(t, source, legacySourcePayloadRoot+"/project-local.md", "do not install\n")
Expand All @@ -183,7 +186,7 @@ func TestPinnedSourceTargetRootWinsOverLockedProjectLocalRootForInitAndUpdate(t
t.Fatalf("init installed non-target payload: %q", got)
}

write(t, source, targetSourcePayloadRoot+"/dna/rule.md", "target v2\n")
write(t, source, targetSourcePayloadRoot+"/memory-bank/dna/rule.md", "target v2\n")
runGitTest(t, source, "add", "--all")
runGitTest(t, source, "-c", "user.name=Memory Bank Tests", "-c", "user.email=tests@example.invalid", "commit", "--quiet", "-m", "target update")
secondCommit := runGitTest(t, source, "rev-parse", "HEAD")
Expand All @@ -200,6 +203,75 @@ func TestPinnedSourceTargetRootWinsOverLockedProjectLocalRootForInitAndUpdate(t
}
}

func TestCanonicalTemplateIncludesAllTrackedFiles(t *testing.T) {
source, repo := t.TempDir(), t.TempDir()
write(t, source, "template/memory-bank/dna/rule.md", "rule\n")
write(t, source, "template/.config/hidden", "hidden\n")
write(t, source, "template/.github/workflows/check.yml", "name: check\n")
tool := filepath.Join(source, "template", "bin", "tool")
write(t, source, "template/bin/tool", "#!/bin/sh\n")
if err := os.Chmod(tool, 0o755); err != nil {
t.Fatal(err)
}
commit := commitTestSource(t, source)
report, err := Init(Options{RepoRoot: repo, SourceRoot: source, TemplateVersion: "v1", SourceRef: commit})
if err != nil || !report.Applied {
t.Fatalf("init failed: report=%#v err=%v", report, err)
}
for path, want := range map[string]string{
"memory-bank/dna/rule.md": "rule\n",
".config/hidden": "hidden\n",
".github/workflows/check.yml": "name: check\n",
"bin/tool": "#!/bin/sh\n",
} {
if got := read(t, repo, path); got != want {
t.Fatalf("%s = %q, want %q", path, got, want)
}
}
info, err := os.Stat(filepath.Join(repo, "bin", "tool"))
if err != nil || info.Mode().Perm() != 0o755 {
t.Fatalf("executable mode = %v, %v", info.Mode(), err)
}
lock, exists, err := ReadLock(repo)
if err != nil || !exists || lock.Files[".config/hidden"].Ownership != Managed {
t.Fatalf("canonical paths were not managed in lock: %#v, exists=%v, err=%v", lock, exists, err)
}
}

func TestCanonicalTemplateOwnsAgentFileWhenPresent(t *testing.T) {
source, repo := t.TempDir(), t.TempDir()
write(t, source, "template/AGENTS.md", "template instructions\n")
write(t, source, "template/memory-bank/dna/rule.md", "rule\n")
firstCommit := commitTestSource(t, source)

report, err := Init(Options{RepoRoot: repo, SourceRoot: source, TemplateVersion: "v1", SourceRef: firstCommit})
if err != nil || !report.Applied {
t.Fatalf("init failed: report=%#v err=%v", report, err)
}
if got := read(t, repo, "AGENTS.md"); got != "template instructions\n" {
t.Fatalf("template agent file = %q", got)
}
if strings.Contains(read(t, repo, "AGENTS.md"), "MEMORY BANK START") {
t.Fatal("template agent file was rewritten by the generated instruction plan")
}
lock, exists, err := ReadLock(repo)
if err != nil || !exists || lock.Files["AGENTS.md"].Ownership != Managed {
t.Fatalf("template agent file was not locked as managed: %#v, exists=%v, err=%v", lock, exists, err)
}

write(t, source, "template/AGENTS.md", "updated template instructions\n")
runGitTest(t, source, "add", "--all")
runGitTest(t, source, "-c", "user.name=Memory Bank Tests", "-c", "user.email=tests@example.invalid", "commit", "--quiet", "-m", "update template instructions")
secondCommit := runGitTest(t, source, "rev-parse", "HEAD")
report, err = Update(Options{RepoRoot: repo, SourceRoot: source, TemplateVersion: "v2", SourceRef: secondCommit})
if err != nil || !report.Applied || decisionFor(t, report, "AGENTS.md").Action != UpdateFile {
t.Fatalf("update failed: report=%#v err=%v", report, err)
}
if got := read(t, repo, "AGENTS.md"); got != "updated template instructions\n" {
t.Fatalf("updated template agent file = %q", got)
}
}

func TestPinnedSourceObjectsIgnoreHiddenWorktreeChanges(t *testing.T) {
for _, test := range []struct {
name string
Expand Down
46 changes: 35 additions & 11 deletions internal/ownership/update.go
Original file line number Diff line number Diff line change
Expand Up @@ -22,6 +22,7 @@ type payload struct {
data []byte
digest string
mode string
class Class
}

type mutation struct {
Expand Down Expand Up @@ -116,13 +117,19 @@ func run(options Options, old Lock, hasLock bool, repo pinnedRepo, lockDigest st
return Report{}, err
}
templateMutationCount := len(mutations)
agentMutation, agentDecision, err := buildAgentPlan(repo, options.AgentFile)
if err != nil {
return Report{}, err
agentTarget := options.AgentFile
if agentTarget == "" {
agentTarget = agentinstructions.DefaultTarget
}
decisions = append(decisions, agentDecision)
if agentMutation != nil {
mutations = append(mutations, *agentMutation)
if _, templateOwnsAgentFile := source[filepath.ToSlash(agentTarget)]; !templateOwnsAgentFile {
agentMutation, agentDecision, err := buildAgentPlan(repo, options.AgentFile)
if err != nil {
return Report{}, err
}
decisions = append(decisions, agentDecision)
if agentMutation != nil {
mutations = append(mutations, *agentMutation)
}
}
report := Report{FormatVersion: ReportFormatVersion, DryRun: options.DryRun, Decisions: decisions}
for _, decision := range decisions {
Expand Down Expand Up @@ -298,7 +305,7 @@ func readSource(source pinnedSource) (map[string]payload, error) {
if err != nil {
return err
}
result[downstreamPayloadPath(filepath.ToSlash(relative))] = payload{data: data, digest: digest(data), mode: gitMode(info.Mode().Perm())}
result[downstreamPayloadPath(payloadRoot, filepath.ToSlash(relative))] = payload{data: data, digest: digest(data), mode: gitMode(info.Mode().Perm()), class: sourcePayloadClass(payloadRoot, filepath.ToSlash(relative))}
return nil
})
if errors.Is(err, os.ErrNotExist) {
Expand Down Expand Up @@ -343,7 +350,7 @@ func readGitSource(source pinnedSource, ref string) (map[string]payload, error)
if relative == filePath || relative == "" {
return nil, fmt.Errorf("read pinned source tree: invalid payload path %q", filePath)
}
result[downstreamPayloadPath(relative)] = payload{data: data, digest: digest(data), mode: fields[0]}
result[downstreamPayloadPath(payloadRoot, relative)] = payload{data: data, digest: digest(data), mode: fields[0], class: sourcePayloadClass(payloadRoot, relative)}
}
if len(result) == 0 {
return nil, fmt.Errorf("template source has no %s payload", payloadRoot)
Expand All @@ -354,8 +361,25 @@ func readGitSource(source pinnedSource, ref string) (map[string]payload, error)
return result, nil
}

func downstreamPayloadPath(relative string) string {
return downstreamPayloadRoot + "/" + strings.TrimPrefix(filepath.ToSlash(relative), "/")
func downstreamPayloadPath(payloadRoot, relative string) string {
relative = strings.TrimPrefix(filepath.ToSlash(relative), "/")
if payloadRoot != targetSourcePayloadRoot {
return downstreamPayloadRoot + "/" + relative
}
if relative == downstreamPayloadRoot {
return downstreamPayloadRoot
}
if strings.HasPrefix(relative, downstreamPayloadRoot+"/") {
return relative
}
return relative
}

func sourcePayloadClass(payloadRoot, relative string) Class {
if payloadRoot == targetSourcePayloadRoot {
return Managed
}
return Classify(downstreamPayloadPath(payloadRoot, relative))
}

func gitMode(mode fs.FileMode) string {
Expand Down Expand Up @@ -455,7 +479,7 @@ func buildPlan(repo pinnedRepo, source map[string]payload, old Lock, hasLock boo
sort.Strings(paths)
for _, path := range paths {
incoming := source[path]
class := Classify(path)
class := incoming.class
prior, tracked := old.Files[path]
currentDigest, exists, topology, err := inspectDestinationForPlan(repo, path, cleanRemovals)
if err != nil {
Expand Down
45 changes: 43 additions & 2 deletions internal/ownership/update_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -70,6 +70,21 @@ func TestAgentFileRejectsCaseAliasesOfMemoryBank(t *testing.T) {
}
}

func TestReadLockRejectsAbsoluteManagedPath(t *testing.T) {
repo, source := t.TempDir(), t.TempDir()
write(t, source, "memory-bank/dna/rule.md", "managed\n")
initialize(t, repo, source)
lockPath := filepath.Join(repo, filepath.FromSlash(LockFileName))
lock := read(t, repo, LockFileName)
lock = strings.Replace(lock, "memory-bank/dna/rule.md", "/outside.md", 1)
if err := os.WriteFile(lockPath, []byte(lock), 0o644); err != nil {
t.Fatal(err)
}
if _, _, err := ReadLock(repo); err == nil || !strings.Contains(err.Error(), "invalid path") {
t.Fatalf("absolute lock path was accepted: %v", err)
}
}

func TestAgentPlanPreservesExplicitZeroMode(t *testing.T) {
if runtime.GOOS == "windows" {
t.Skip("Windows does not expose Unix permission bits")
Expand Down Expand Up @@ -132,7 +147,7 @@ func TestFilesystemSourceReaderTranslatesTargetRoot(t *testing.T) {
if got := read(t, repo, "memory-bank/dna/rule.md"); got != "template\n" {
t.Fatalf("target-root filesystem reader did not translate downstream path: %q", got)
}
if _, err := os.Stat(filepath.Join(repo, "template", "memory-bank")); !os.IsNotExist(err) {
if _, err := os.Stat(filepath.Join(repo, "template")); !os.IsNotExist(err) {
t.Fatalf("source root leaked into downstream: %v", err)
}
}
Expand All @@ -151,7 +166,7 @@ func TestUpdateFromTargetRootPreservesDownstreamPathAndIsIdempotent(t *testing.T
if got := read(t, repo, path); got != "two\n" {
t.Fatalf("target-root update wrote unexpected downstream payload: %q", got)
}
if _, err := os.Stat(filepath.Join(repo, "template", "memory-bank")); !os.IsNotExist(err) {
if _, err := os.Stat(filepath.Join(repo, "template")); !os.IsNotExist(err) {
t.Fatalf("source root leaked into downstream after update: %v", err)
}

Expand All @@ -165,6 +180,32 @@ func TestUpdateFromTargetRootPreservesDownstreamPathAndIsIdempotent(t *testing.T
}
}

func TestLegacyLockMigratesToCanonicalTemplateWithoutRemovingManagedFiles(t *testing.T) {
repo, source := t.TempDir(), t.TempDir()
write(t, source, "memory-bank/dna/rule.md", "v1\n")
initialize(t, repo, source)

if err := os.RemoveAll(filepath.Join(source, "memory-bank")); err != nil {
t.Fatal(err)
}
write(t, source, "template/memory-bank/dna/rule.md", "v1\n")
write(t, source, "template/.config/new", "new\n")
report, err := Update(opts(repo, source, "b"))
if err != nil || !report.Applied {
t.Fatalf("canonical migration failed: report=%#v err=%v", report, err)
}
if got := read(t, repo, "memory-bank/dna/rule.md"); got != "v1\n" {
t.Fatalf("legacy managed file changed during migration: %q", got)
}
if got := read(t, repo, ".config/new"); got != "new\n" {
t.Fatalf("canonical addition was not installed: %q", got)
}
lock, exists, err := ReadLock(repo)
if err != nil || !exists || lock.Files["memory-bank/dna/rule.md"].Ownership != Managed || lock.Files[".config/new"].Ownership != Managed {
t.Fatalf("migration lock is incomplete: %#v, exists=%v, err=%v", lock, exists, err)
}
}

func TestInitRejectsReservedLockPathInTemplate(t *testing.T) {
repo, source := t.TempDir(), t.TempDir()
write(t, source, "memory-bank/dna/rule.md", "template\n")
Expand Down
Loading
Loading