diff --git a/README.md b/README.md index 3d5133b..d4754cc 100644 --- a/README.md +++ b/README.md @@ -183,6 +183,17 @@ Notes for `dv reset git`: - Syncs with the upstream branch. - Reinstalls dependencies and runs migrations. +### dv catchup +Bring a container up to date: reset core to its upstream branch, reset and update each plugin that has its own git repository, reinstall dependencies, and migrate both databases. + +```bash +dv catchup [--name NAME] [-y] +``` + +Notes: +- The confirmation lists every repo that will be reset. `-y` skips the prompt. +- Plugins bind-mounted from the host (`dv new --plugin-local`) are never touched: they are the host's working copy, and a reset inside the container would discard uncommitted work on the host. + ### dv console Open an interactive Rails console in a running or stopped Discourse container. diff --git a/internal/cli/catchup.go b/internal/cli/catchup.go index 45c9ba2..f4205d8 100644 --- a/internal/cli/catchup.go +++ b/internal/cli/catchup.go @@ -2,6 +2,7 @@ package cli import ( "fmt" + "path" "strings" "github.com/spf13/cobra" @@ -66,14 +67,23 @@ func catchupRunE(cmd *cobra.Command, args []string) error { fmt.Fprintf(cmd.ErrOrStderr(), "Warning: failed to discover plugin repos: %v\n", err) pluginOutput = "" } - var plugins []string + var discovered []string for _, line := range strings.Split(strings.TrimSpace(pluginOutput), "\n") { line = strings.TrimSpace(line) if line != "" { - plugins = append(plugins, line) + discovered = append(discovered, line) } } + // A bind-mounted plugin (dv new --plugin-local) is the host's checkout: + // resetting it inside the container discards work on the host. + mounts, err := docker.GetContainerMounts(name) + if err != nil { + fmt.Fprintf(cmd.ErrOrStderr(), "Warning: failed to inspect container mounts: %v\n", err) + mounts = nil + } + plugins, mounted := splitMountedPlugins(discovered, workdir, mounts) + // Confirmation prompt skipConfirm, _ := cmd.Flags().GetBool("yes") if !skipConfirm { @@ -84,6 +94,7 @@ func catchupRunE(cmd *cobra.Command, args []string) error { for _, p := range plugins { fmt.Fprintf(cmd.OutOrStdout(), " - %s\n", p) } + printMountedPlugins(cmd, mounted) fmt.Fprintln(cmd.OutOrStdout(), "") yes, err := promptYesNo(cmd.InOrStdin(), cmd.OutOrStdout(), "Continue? (y/N): ") if err != nil { @@ -93,6 +104,8 @@ func catchupRunE(cmd *cobra.Command, args []string) error { fmt.Fprintln(cmd.OutOrStdout(), "Aborted.") return nil } + } else { + printMountedPlugins(cmd, mounted) } fmt.Fprintf(cmd.OutOrStdout(), "Catching up in container '%s'...\n", name) @@ -105,6 +118,35 @@ func catchupRunE(cmd *cobra.Command, args []string) error { return nil } +// splitMountedPlugins separates discovered plugin repos (relative to workdir, +// "plugins/") into those catchup may reset and those whose directory is +// a container mount. A mounted plugin is the host's working copy and is never +// reset. +func splitMountedPlugins(plugins []string, workdir string, mounts []docker.Mount) (reset, mounted []string) { + destinations := make(map[string]bool, len(mounts)) + for _, m := range mounts { + destinations[path.Clean(m.Container)] = true + } + for _, p := range plugins { + if destinations[path.Clean(path.Join(workdir, p))] { + mounted = append(mounted, p) + } else { + reset = append(reset, p) + } + } + return reset, mounted +} + +func printMountedPlugins(cmd *cobra.Command, mounted []string) { + if len(mounted) == 0 { + return + } + fmt.Fprintln(cmd.OutOrStdout(), "Plugins left alone (bind-mounted from the host):") + for _, p := range mounted { + fmt.Fprintf(cmd.OutOrStdout(), " - %s\n", p) + } +} + func buildCatchupScript(workdir string, plugins []string) string { lines := []string{ "set -euo pipefail", diff --git a/internal/cli/catchup_test.go b/internal/cli/catchup_test.go index d6ba348..556a68e 100644 --- a/internal/cli/catchup_test.go +++ b/internal/cli/catchup_test.go @@ -3,6 +3,8 @@ package cli import ( "strings" "testing" + + "dv/internal/docker" ) func TestBuildCatchupScript_CoreOnly(t *testing.T) { @@ -142,3 +144,40 @@ func TestBuildCatchupScriptWaitsForPostgresBeforeMigrations(t *testing.T) { t.Fatal("catchup must abort when PostgreSQL readiness times out") } } + +func TestSplitMountedPlugins_LeavesBindMountedPluginsAlone(t *testing.T) { + t.Parallel() + + plugins := []string{"plugins/discourse-ai", "plugins/second-brain"} + mounts := []docker.Mount{{Host: "/home/me/work/second-brain", Container: "/var/www/discourse/plugins/second-brain"}} + + reset, mounted := splitMountedPlugins(plugins, "/var/www/discourse", mounts) + + if len(reset) != 1 || reset[0] != "plugins/discourse-ai" { + t.Errorf("cloned plugin should be reset: %v", reset) + } + if len(mounted) != 1 || mounted[0] != "plugins/second-brain" { + t.Errorf("bind-mounted plugin must be left alone: %v", mounted) + } +} + +func TestSplitMountedPlugins_IgnoresUnrelatedMounts(t *testing.T) { + t.Parallel() + + mounts := []docker.Mount{{Host: "/home/me/.ssh", Container: "/home/discourse/.ssh"}} + reset, mounted := splitMountedPlugins([]string{"plugins/discourse-ai"}, "/var/www/discourse", mounts) + + if len(reset) != 1 || len(mounted) != 0 { + t.Errorf("unrelated mounts must not exclude plugins: reset=%v mounted=%v", reset, mounted) + } +} + +func TestSplitMountedPlugins_NoMounts(t *testing.T) { + t.Parallel() + + reset, mounted := splitMountedPlugins([]string{"plugins/a", "plugins/b"}, "/var/www/discourse", nil) + + if len(reset) != 2 || len(mounted) != 0 { + t.Errorf("without mounts every plugin is reset: reset=%v mounted=%v", reset, mounted) + } +}