Skip to content
Open
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
11 changes: 11 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.

Expand Down
46 changes: 44 additions & 2 deletions internal/cli/catchup.go
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@ package cli

import (
"fmt"
"path"
"strings"

"github.com/spf13/cobra"
Expand Down Expand Up @@ -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 {
Expand All @@ -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 {
Expand All @@ -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)
Expand All @@ -105,6 +118,35 @@ func catchupRunE(cmd *cobra.Command, args []string) error {
return nil
}

// splitMountedPlugins separates discovered plugin repos (relative to workdir,
// "plugins/<name>") 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",
Expand Down
39 changes: 39 additions & 0 deletions internal/cli/catchup_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,8 @@ package cli
import (
"strings"
"testing"

"dv/internal/docker"
)

func TestBuildCatchupScript_CoreOnly(t *testing.T) {
Expand Down Expand Up @@ -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)
}
}
Loading