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
15 changes: 15 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,21 @@ Existing locks from the legacy payload roots are migrated conservatively:
unchanged files adopt canonical ownership, while local customization is
preserved for explicit resolution.

## Resolve update collisions interactively

`update` preserves user-owned files by default. To decide each managed-file
collision interactively, run it from a terminal with `--ask`:

```sh
memory-bank-cli update --ask
```

Choose `keep` to retain the local file and its ownership, or `overwrite` to
replace it (including its executable mode) from the source template. All
answers are collected before changes are applied. `--ask --dry-run` shows the
resolved plan without changing files; `--ask` is rejected when standard input
is not a terminal.

## 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
1 change: 1 addition & 0 deletions go.mod
Original file line number Diff line number Diff line change
Expand Up @@ -4,5 +4,6 @@ go 1.21

require (
golang.org/x/sys v0.17.0
golang.org/x/term v0.17.0
gopkg.in/yaml.v3 v3.0.1
)
2 changes: 2 additions & 0 deletions go.sum
Original file line number Diff line number Diff line change
@@ -1,5 +1,7 @@
golang.org/x/sys v0.17.0 h1:25cE3gD+tdBA7lp7QfhuV+rJiE9YXTcS3VG1SqssI/Y=
golang.org/x/sys v0.17.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA=
golang.org/x/term v0.17.0 h1:mkTF7LCd6WGJNL3K1Ad7kwxNfYAW6a8a8QqtMblp/4U=
golang.org/x/term v0.17.0/go.mod h1:lLRBjIVuehSbZlaOtGMbcMncT+aqLLLmKrsjNrUguwk=
gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405 h1:yhCVgyC4o1eVCa2tZl7eS0r+SDo693bJlVdllGtEeKM=
gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA=
Expand Down
65 changes: 62 additions & 3 deletions internal/cli/cli.go
Original file line number Diff line number Diff line change
Expand Up @@ -2,11 +2,13 @@
package cli

import (
"bufio"
"encoding/json"
"errors"
"flag"
"fmt"
"io"
"os"
"path/filepath"
"sort"
"strings"
Expand All @@ -17,6 +19,7 @@ import (
"github.com/dapi/memory-bank-cli/internal/ownership"
"github.com/dapi/memory-bank-cli/internal/push"
"github.com/dapi/memory-bank-cli/internal/repository"
"golang.org/x/term"
)

const (
Expand Down Expand Up @@ -49,9 +52,9 @@ func Run(arguments []string, version string, stdout, stderr io.Writer) int {
case "lint":
return runLint(arguments[1:], "memory-bank-cli lint", version, stdout, stderr)
case "init":
return runOwnership(arguments[1:], "init", stdout, stderr)
return runOwnership(arguments[1:], "init", os.Stdin, term.IsTerminal(int(os.Stdin.Fd())), stdout, stderr)
case "update":
return runOwnership(arguments[1:], "update", stdout, stderr)
return runOwnership(arguments[1:], "update", os.Stdin, term.IsTerminal(int(os.Stdin.Fd())), stdout, stderr)
case "doctor":
return runDoctor(arguments[1:], stdout, stderr)
case "github":
Expand Down Expand Up @@ -194,7 +197,7 @@ func runGitHubAdapter(arguments []string, stdout, stderr io.Writer) int {
return exitSuccess
}

func runOwnership(arguments []string, command string, stdout, stderr io.Writer) int {
func runOwnership(arguments []string, command string, stdin io.Reader, stdinIsTerminal bool, stdout, stderr io.Writer) int {
flags := flag.NewFlagSet("memory-bank-cli "+command, flag.ContinueOnError)
flags.SetOutput(stderr)
flags.Usage = func() {
Expand All @@ -206,6 +209,7 @@ func runOwnership(arguments []string, command string, stdout, stderr io.Writer)
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")
ask := flags.Bool("ask", false, "interactively resolve user-owned managed-file collisions")
agentFile := flags.String("agent-file", "AGENTS.md", "single repository-relative agent instruction file to manage")
jsonOutput := addJSONOutputFlag(flags)
if err := flags.Parse(arguments); err != nil {
Expand All @@ -218,6 +222,14 @@ func runOwnership(arguments []string, command string, stdout, stderr io.Writer)
fmt.Fprintf(stderr, "memory-bank-cli %s: unexpected arguments: %v\n", command, flags.Args())
return exitUsage
}
if *ask && command != "update" {
fmt.Fprintln(stderr, "memory-bank-cli init: --ask is only supported by update")
return exitUsage
}
if *ask && !stdinIsTerminal {
fmt.Fprintln(stderr, "memory-bank-cli update: --ask requires an interactive terminal; rerun without --ask in CI or piped execution")
return exitFailure
}
explicitSource := *sourceRootArgument != "" || *templateVersion != "" || *sourceRef != ""
if explicitSource && (*sourceRootArgument == "" || *templateVersion == "" || *sourceRef == "") {
fmt.Fprintf(stderr, "memory-bank-cli %s: --source, --template-version, and --source-ref are required\n", command)
Expand All @@ -240,6 +252,21 @@ func runOwnership(arguments []string, command string, stdout, stderr io.Writer)
AgentFile: *agentFile,
}
var report ownership.Report
if *ask {
planOptions := options
planOptions.DryRun = true
plan, planErr := ownership.Update(planOptions)
if planErr != nil {
fmt.Fprintln(stderr, planErr)
return exitFailure
}
overwrites, askErr := askUserOwnedCollisions(stdin, stderr, plan)
if askErr != nil {
fmt.Fprintln(stderr, askErr)
return exitFailure
}
options.UserOwnedResolutions = overwrites
}
if command == "init" {
report, err = ownership.Init(options)
} else {
Expand Down Expand Up @@ -268,6 +295,38 @@ func runOwnership(arguments []string, command string, stdout, stderr io.Writer)
return exitSuccess
}

func askUserOwnedCollisions(stdin io.Reader, writer io.Writer, report ownership.Report) (map[string]bool, error) {
overwrites := make(map[string]bool)
scanner := bufio.NewScanner(stdin)
for _, decision := range report.Decisions {
if !decision.CanOverwrite {
continue
}
for {
fmt.Fprintf(writer, "User-owned collision: %s\nPlanned safe action: keep local file (%s).\nChoose [k]eep local or [o]verwrite from source: ", decision.Path, decision.Reason)
if !scanner.Scan() {
if err := scanner.Err(); err != nil {
return nil, fmt.Errorf("read --ask response: %w", err)
}
return nil, errors.New("read --ask response: input ended before all collisions were resolved; no files changed")
}
switch strings.ToLower(strings.TrimSpace(scanner.Text())) {
case "k", "keep":
overwrites[decision.Path] = false
break
case "o", "overwrite":
overwrites[decision.Path] = true
break
default:
fmt.Fprintln(writer, "Please enter k/keep or o/overwrite.")
continue
}
break
}
}
return overwrites, nil
}

func printOwnershipReport(writer io.Writer, report ownership.Report) {
decisions := append([]ownership.Decision(nil), report.Decisions...)
sort.Slice(decisions, func(i, j int) bool { return decisions[i].Path < decisions[j].Path })
Expand Down
108 changes: 108 additions & 0 deletions internal/cli/cli_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,7 @@ import (
"os"
"os/exec"
"path/filepath"
"runtime"
"strings"
"testing"

Expand Down Expand Up @@ -721,3 +722,110 @@ func TestOwnershipDryRunJSONReportsNewManagedPathCollision(t *testing.T) {
}
t.Fatalf("collision decision missing from report: %#v", report.Decisions)
}

func TestUpdateAskResolvesCollisionsWithoutPartialChanges(t *testing.T) {
repo, source := t.TempDir(), t.TempDir()
seed := filepath.Join(source, "template", "memory-bank", "dna", "seed.md")
if err := os.MkdirAll(filepath.Dir(seed), 0o755); err != nil {
t.Fatal(err)
}
if err := os.WriteFile(seed, []byte("seed\n"), 0o644); err != nil {
t.Fatal(err)
}
initialRef := commitCLISource(t, source, "initial source")
initArgs := []string{"init", "--repo-root", repo, "--source", source, "--template-version", "v1", "--source-ref", initialRef}
var stdout, stderr bytes.Buffer
if exitCode := Run(initArgs, "test", &stdout, &stderr); exitCode != 0 {
t.Fatalf("init exit=%d stderr=%s", exitCode, stderr.String())
}
first := filepath.Join("memory-bank", "dna", "first.md")
second := filepath.Join("memory-bank", "dna", "second.md")
for _, collision := range []string{first, second} {
if err := os.WriteFile(filepath.Join(repo, collision), []byte("local "+collision+"\n"), 0o644); err != nil {
t.Fatal(err)
}
if err := os.WriteFile(filepath.Join(source, "template", collision), []byte("source "+collision+"\n"), 0o644); err != nil {
t.Fatal(err)
}
}
if err := os.Chmod(filepath.Join(repo, second), 0o755); err != nil {
t.Fatal(err)
}
updatedRef := commitCLISource(t, source, "add collisions")
updateArgs := []string{"--repo-root", repo, "--source", source, "--template-version", "v2", "--source-ref", updatedRef, "--ask"}
stdout.Reset()
stderr.Reset()
if exitCode := runOwnership(updateArgs, "update", strings.NewReader("bad\no\nk\n"), true, &stdout, &stderr); exitCode != 0 {
t.Fatalf("ask update exit=%d stdout=%s stderr=%s", exitCode, stdout.String(), stderr.String())
}
if !strings.Contains(stderr.String(), "User-owned collision: "+filepath.ToSlash(first)) || !strings.Contains(stderr.String(), "Please enter k/keep or o/overwrite.") {
t.Fatalf("ask prompt omitted collision context or invalid-input guidance: %s", stderr.String())
}
if got, want := string(mustReadFile(t, filepath.Join(repo, first))), "source "+first+"\n"; got != want {
t.Fatalf("overwrite payload=%q want=%q", got, want)
}
if got, want := string(mustReadFile(t, filepath.Join(repo, second))), "local "+second+"\n"; got != want {
t.Fatalf("keep payload=%q want=%q", got, want)
}
if runtime.GOOS != "windows" {
if info, err := os.Stat(filepath.Join(repo, second)); err != nil || info.Mode().Perm() != 0o755 {
t.Fatalf("keep mode=%v err=%v, want 0755", info, err)
}
}
lock, exists, err := ownership.ReadLock(repo)
if err != nil || !exists || lock.Files[filepath.ToSlash(first)].Ownership != ownership.Managed || lock.Files[filepath.ToSlash(second)].Ownership != ownership.UserOwned {
t.Fatalf("ask resolutions not recorded: lock=%#v exists=%v err=%v", lock.Files, exists, err)
}

lockBefore := mustReadFile(t, filepath.Join(repo, "memory-bank", ".lock"))
stdout.Reset()
stderr.Reset()
if exitCode := runOwnership(append(append([]string{}, updateArgs...), "--dry-run"), "update", strings.NewReader("o\n"), true, &stdout, &stderr); exitCode != 0 {
t.Fatalf("ask dry-run exit=%d stderr=%s", exitCode, stderr.String())
}
if got := string(mustReadFile(t, filepath.Join(repo, second))); got != "local "+second+"\n" {
t.Fatalf("ask dry-run changed payload: %q", got)
}
if got := mustReadFile(t, filepath.Join(repo, "memory-bank", ".lock")); !bytes.Equal(got, lockBefore) {
t.Fatal("ask dry-run changed lock")
}
if !strings.Contains(stdout.String(), "update\tmanaged\t"+filepath.ToSlash(second)+"\treplace user-owned file from source by explicit resolution") {
t.Fatalf("ask dry-run did not print resolved plan: %s", stdout.String())
}

third := filepath.Join("memory-bank", "dna", "third.md")
if err := os.WriteFile(filepath.Join(repo, third), []byte("local third\n"), 0o644); err != nil {
t.Fatal(err)
}
if err := os.WriteFile(filepath.Join(source, "template", third), []byte("source third\n"), 0o644); err != nil {
t.Fatal(err)
}
thirdRef := commitCLISource(t, source, "add third collision")
attemptArgs := []string{"--repo-root", repo, "--source", source, "--template-version", "v3", "--source-ref", thirdRef, "--ask"}
stdout.Reset()
stderr.Reset()
if exitCode := runOwnership(attemptArgs, "update", strings.NewReader("o\n"), true, &stdout, &stderr); exitCode != exitFailure || !strings.Contains(stderr.String(), "input ended") {
t.Fatalf("incomplete answers exit=%d stderr=%s", exitCode, stderr.String())
}
if got := string(mustReadFile(t, filepath.Join(repo, second))); got != "local "+second+"\n" {
t.Fatalf("incomplete answers partially changed earlier collision: %q", got)
}
if got := string(mustReadFile(t, filepath.Join(repo, third))); got != "local third\n" {
t.Fatalf("incomplete answers changed later collision: %q", got)
}

stdout.Reset()
stderr.Reset()
if exitCode := runOwnership([]string{"--ask"}, "update", strings.NewReader("o\n"), false, &stdout, &stderr); exitCode != exitFailure || !strings.Contains(stderr.String(), "requires an interactive terminal") {
t.Fatalf("non-interactive --ask exit=%d stderr=%s", exitCode, stderr.String())
}
}

func mustReadFile(t *testing.T, path string) []byte {
t.Helper()
data, err := os.ReadFile(path)
if err != nil {
t.Fatal(err)
}
return data
}
61 changes: 61 additions & 0 deletions internal/ownership/plan_regression_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@ import (
"errors"
"os"
"path/filepath"
"runtime"
"strings"
"testing"
"time"
Expand Down Expand Up @@ -133,6 +134,66 @@ func TestCollisionWithNewManagedFileIsRejected(t *testing.T) {
}
}

func TestExplicitUserOwnedOverwriteReplacesPayloadAndMode(t *testing.T) {
if runtime.GOOS == "windows" {
t.Skip("Windows does not expose Unix executable bits")
}
repo, source := t.TempDir(), t.TempDir()
seed := "memory-bank/dna/seed.md"
path := "memory-bank/dna/collision.sh"
write(t, source, "template/"+seed, "seed\n")
initialize(t, repo, source)
write(t, repo, path, "local\n")
write(t, source, "template/"+path, "#!/bin/sh\necho source\n")
if err := os.Chmod(filepath.Join(source, "template", filepath.FromSlash(path)), 0o755); err != nil {
t.Fatal(err)
}

options := opts(repo, source, "b")
options.UserOwnedResolutions = map[string]bool{path: true}
report, err := Update(options)
decision := decisionFor(t, report, path)
if err != nil || !report.Applied || decision.Action != UpdateFile || decision.Ownership != Managed {
t.Fatalf("explicit overwrite did not resolve collision: report=%#v err=%v", report, err)
}
if got := read(t, repo, path); got != "#!/bin/sh\necho source\n" {
t.Fatalf("overwrite did not install source payload: %q", got)
}
info, err := os.Stat(filepath.Join(repo, filepath.FromSlash(path)))
if err != nil || info.Mode().Perm() != 0o755 {
t.Fatalf("overwrite did not install source mode: info=%v err=%v", info, err)
}
lock, exists, err := ReadLock(repo)
if err != nil || !exists || lock.Files[path].Ownership != Managed || lock.Files[path].PayloadMode != "100755" {
t.Fatalf("overwrite did not record managed ownership: lock=%#v exists=%v err=%v", lock.Files[path], exists, err)
}
}

func TestExplicitUserOwnedKeepPreservesOwnership(t *testing.T) {
repo, source := t.TempDir(), t.TempDir()
seed := "memory-bank/dna/seed.md"
path := "memory-bank/dna/collision.md"
write(t, source, "template/"+seed, "seed\n")
write(t, source, "template/"+path, "source\n")
write(t, repo, path, "local\n")
initialize(t, repo, source)

options := opts(repo, source, "b")
options.UserOwnedResolutions = map[string]bool{path: false}
report, err := Update(options)
decision := decisionFor(t, report, path)
if err != nil || !report.Applied || decision.Action != Preserve || decision.Reason != "keep user-owned file by explicit resolution" || decision.Ownership != UserOwned {
t.Fatalf("explicit keep did not preserve collision: report=%#v err=%v", report, err)
}
if got := read(t, repo, path); got != "local\n" {
t.Fatalf("keep replaced local payload: %q", got)
}
lock, exists, err := ReadLock(repo)
if err != nil || !exists || lock.Files[path].Ownership != UserOwned {
t.Fatalf("keep did not preserve user-owned lock entry: lock=%#v exists=%v err=%v", lock.Files[path], exists, err)
}
}

func TestManagedEditAfterPlanningIsNotOverwritten(t *testing.T) {
repo, source := t.TempDir(), t.TempDir()
path := "memory-bank/dna/rule.md"
Expand Down
19 changes: 12 additions & 7 deletions internal/ownership/types.go
Original file line number Diff line number Diff line change
Expand Up @@ -54,11 +54,12 @@ const (
)

type Decision struct {
Path string `json:"path"`
Ownership Class `json:"ownership"`
Action Action `json:"action"`
Reason string `json:"reason"`
Diff string `json:"diff,omitempty"`
Path string `json:"path"`
Ownership Class `json:"ownership"`
Action Action `json:"action"`
Reason string `json:"reason"`
Diff string `json:"diff,omitempty"`
CanOverwrite bool `json:"-"`
}

type Report struct {
Expand All @@ -76,8 +77,12 @@ type Options struct {
TemplateVersion string
SourceRef string
DryRun bool
AgentFile string
Now func() time.Time
// UserOwnedResolutions maps user-owned managed-file collisions to their
// explicit resolution: false keeps local content, true replaces it with the
// incoming source payload.
UserOwnedResolutions map[string]bool
AgentFile string
Now func() time.Time
// verifySource is replaced by unit tests that use synthetic source trees.
// CLI callers always use the Git-backed provenance verifier.
verifySource func(string, string) error
Expand Down
Loading
Loading