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
8 changes: 7 additions & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -312,8 +312,14 @@ completing.
```bash
wl sync # pull upstream changes into your fork
wl sync --dry-run # preview what would change
wl sync --upgrade # allow major schema version upgrades
```

Schema version changes are detected automatically during sync:

- **MINOR** bumps (e.g. 1.1 → 1.2) are applied automatically
- **MAJOR** bumps (e.g. 1.x → 2.x) are blocked until you pass `--upgrade`

## Diagnostics

```bash
Expand Down Expand Up @@ -497,7 +503,7 @@ and the SPA from a single process with no external dependencies.
| `wl update <id>` | Update an open item | `--title`, `--priority`, `--effort`, `--type`, `--tags`, `--project` |
| `wl unclaim <id>` | Release back to open | `--no-push` |
| `wl delete <id>` | Withdraw an open item | `--no-push` |
| `wl sync` | Pull upstream into fork | `--dry-run` |
| `wl sync` | Pull upstream into fork | `--dry-run`, `--upgrade` |
| `wl review [branch]` | List or diff PR-mode branches | `--stat`, `--md`, `--json`, `--create-pr` |
| `wl approve <branch>` | Approve a PR-mode branch | `--comment` |
| `wl request-changes <branch>` | Request changes on a branch | `--comment` (required) |
Expand Down
93 changes: 81 additions & 12 deletions cmd/wl/cmd_sync.go
Original file line number Diff line number Diff line change
Expand Up @@ -5,13 +5,16 @@ import (
"io"
"os/exec"

"github.com/gastownhall/wasteland/internal/backend"
"github.com/gastownhall/wasteland/internal/commons"
"github.com/gastownhall/wasteland/internal/federation"
"github.com/gastownhall/wasteland/internal/style"
"github.com/spf13/cobra"
)

func newSyncCmd(stdout, stderr io.Writer) *cobra.Command {
var dryRun bool
var upgrade bool

cmd := &cobra.Command{
Use: "sync",
Expand All @@ -22,20 +25,26 @@ func newSyncCmd(stdout, stderr io.Writer) *cobra.Command {
If you have a local fork of wl-commons (created by wl join), this pulls
the latest changes from upstream.

Schema version changes are detected automatically:
- MINOR version bumps (e.g. 1.1 → 1.2) are applied automatically
- MAJOR version bumps (e.g. 1.x → 2.x) require --upgrade to proceed

EXAMPLES:
wl sync # Pull upstream changes
wl sync --dry-run # Show what would change`,
wl sync --dry-run # Show what would change
wl sync --upgrade # Allow major schema version upgrades`,
RunE: func(cmd *cobra.Command, _ []string) error {
return runSync(cmd, stdout, stderr, dryRun)
return runSync(cmd, stdout, stderr, dryRun, upgrade)
},
}

cmd.Flags().BoolVar(&dryRun, "dry-run", false, "Show what would change without pulling")
cmd.Flags().BoolVar(&upgrade, "upgrade", false, "Allow major schema version upgrades")

return cmd
}

func runSync(cmd *cobra.Command, stdout, stderr io.Writer, dryRun bool) error {
func runSync(cmd *cobra.Command, stdout, stderr io.Writer, dryRun, upgrade bool) error {
cfg, err := resolveWasteland(cmd)
if err != nil {
return hintWrap(err)
Expand All @@ -60,21 +69,27 @@ func runSync(cmd *cobra.Command, stdout, stderr io.Writer, dryRun bool) error {

fmt.Fprintf(stdout, "Local fork: %s\n", style.Dim.Render(forkDir))

// Fetch upstream before version check (non-destructive).
fetchCmd := exec.Command(doltPath, "fetch", "upstream")
fetchCmd.Dir = forkDir
fetchCmd.Stderr = stderr
if err := fetchCmd.Run(); err != nil {
return fmt.Errorf("fetching upstream: %w", err)
}

// Check schema versions.
db := backend.NewLocalDB(forkDir, cfg.Mode)
versionMsg, err := checkSchemaVersion(db, stdout, upgrade, dryRun)
if err != nil {
return err
}

if dryRun {
fmt.Fprintf(stdout, "\n%s Dry run — checking upstream for changes...\n", style.Bold.Render("~"))

fetchCmd := exec.Command(doltPath, "fetch", "upstream")
fetchCmd.Dir = forkDir
fetchCmd.Stderr = stderr
if err := fetchCmd.Run(); err != nil {
return fmt.Errorf("fetching upstream: %w", err)
}

diffCmd := exec.Command(doltPath, "diff", "--stat", "HEAD", "upstream/main")
diffCmd.Dir = forkDir
diffCmd.Stderr = stderr
// dolt diff exits non-zero when differences exist, so ignore the
// error when stdout captured output (meaning changes were found).
diffOut, _ := diffCmd.Output()
if len(diffOut) > 0 {
fmt.Fprint(stdout, string(diffOut))
Expand All @@ -95,6 +110,9 @@ func runSync(cmd *cobra.Command, stdout, stderr io.Writer, dryRun bool) error {
}

fmt.Fprintf(stdout, "\n%s Synced with upstream\n", style.Bold.Render("✓"))
if versionMsg != "" {
fmt.Fprintf(stdout, " %s\n", versionMsg)
}
updateSyncTimestamp(cfg)

// Show summary
Expand All @@ -120,3 +138,54 @@ func runSync(cmd *cobra.Command, stdout, stderr io.Writer, dryRun bool) error {

return nil
}

// checkSchemaVersion compares local and upstream schema versions and returns
// a message to display after sync. Returns an error if a MAJOR bump is
// detected and neither --upgrade nor --dry-run was passed.
// In dry-run mode, major bumps produce a warning but don't block.
func checkSchemaVersion(db commons.DB, stdout io.Writer, upgrade, dryRun bool) (string, error) {
localVer, err := commons.ReadSchemaVersion(db, "")
if err != nil {
// Non-fatal: if we can't read the version, proceed with sync.
return "", nil
}

upstreamVer, err := commons.ReadSchemaVersion(db, "upstream/main")
if err != nil {
return "", nil
}

// If either version is missing, proceed without gating.
if localVer.IsZero() || upstreamVer.IsZero() {
return "", nil
}

delta := commons.CompareVersions(localVer, upstreamVer)

switch delta {
case commons.VersionSame:
return "", nil

case commons.VersionMinor:
return fmt.Sprintf("Schema updated: %s → %s (backwards-compatible)", localVer, upstreamVer), nil

case commons.VersionMajor:
if upgrade {
return fmt.Sprintf("Schema upgraded: %s → %s (major version)", localVer, upstreamVer), nil
}
fmt.Fprintf(stdout, "\n %s Schema upgrade required: %s → %s\n\n",
style.Warning.Render(style.IconWarn), localVer, upstreamVer)
fmt.Fprintf(stdout, " This is a MAJOR version change that may affect your local data.\n")
fmt.Fprintf(stdout, " Review the changelog before upgrading.\n\n")
fmt.Fprintf(stdout, " To proceed: wl sync --upgrade\n")
if dryRun {
return fmt.Sprintf("Schema upgrade required: %s → %s (major version)", localVer, upstreamVer), nil
}
return "", fmt.Errorf("major schema upgrade required (use --upgrade to proceed)")

case commons.VersionAhead:
return fmt.Sprintf("Note: local schema (%s) is ahead of upstream (%s)", localVer, upstreamVer), nil
}

return "", nil
}
105 changes: 105 additions & 0 deletions internal/commons/schema.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,105 @@
package commons

import (
"fmt"
"strconv"
"strings"
)

// SchemaVersion represents a MAJOR.MINOR version pair from _meta.schema_version.
type SchemaVersion struct {
Major int
Minor int
Raw string
}

// String returns the canonical "MAJOR.MINOR" representation.
func (v SchemaVersion) String() string {
return fmt.Sprintf("%d.%d", v.Major, v.Minor)
}

// IsZero returns true if the version was not set (e.g. missing _meta table).
func (v SchemaVersion) IsZero() bool {
return v.Raw == ""
}

// ParseSchemaVersion parses a version string like "1.2" into a SchemaVersion.
func ParseSchemaVersion(s string) (SchemaVersion, error) {
s = strings.TrimSpace(s)
if s == "" {
return SchemaVersion{}, fmt.Errorf("empty schema version")
}

parts := strings.SplitN(s, ".", 2)
if len(parts) != 2 {
return SchemaVersion{}, fmt.Errorf("invalid schema version %q: expected MAJOR.MINOR", s)
}

major, err := strconv.Atoi(parts[0])
if err != nil {
return SchemaVersion{}, fmt.Errorf("invalid schema version %q: bad MAJOR: %w", s, err)
}

minor, err := strconv.Atoi(parts[1])
if err != nil {
return SchemaVersion{}, fmt.Errorf("invalid schema version %q: bad MINOR: %w", s, err)
}

return SchemaVersion{Major: major, Minor: minor, Raw: s}, nil
}

// VersionDelta describes the relationship between local and upstream schema versions.
type VersionDelta int

const (
// VersionSame means versions are identical.
VersionSame VersionDelta = iota
// VersionMinor means upstream has a higher MINOR (backwards-compatible).
VersionMinor
// VersionMajor means upstream has a higher MAJOR (potentially breaking).
VersionMajor
// VersionAhead means local is ahead of upstream (unusual).
VersionAhead
)

// CompareVersions returns the delta between local and upstream versions.
func CompareVersions(local, upstream SchemaVersion) VersionDelta {
if local.Major == upstream.Major && local.Minor == upstream.Minor {
return VersionSame
}
if upstream.Major > local.Major {
return VersionMajor
}
if upstream.Major < local.Major {
return VersionAhead
}
// Same major
if upstream.Minor > local.Minor {
return VersionMinor
}
return VersionAhead
}

// ReadSchemaVersion queries _meta.schema_version from a dolt database at the given ref.
// ref="" queries local HEAD, "upstream/main" queries the upstream ref.
// Returns a zero SchemaVersion if _meta doesn't exist or has no schema_version row.
func ReadSchemaVersion(db DB, ref string) (SchemaVersion, error) {
query := "SELECT value FROM _meta WHERE `key` = 'schema_version'"
out, err := db.Query(query, ref)
if err != nil {
// Table might not exist in very old databases.
return SchemaVersion{}, nil
}

lines := strings.Split(strings.TrimSpace(out), "\n")
if len(lines) < 2 {
return SchemaVersion{}, nil
}

raw := strings.TrimSpace(lines[1])
if raw == "" {
return SchemaVersion{}, nil
}

return ParseSchemaVersion(raw)
}
99 changes: 99 additions & 0 deletions internal/commons/schema_test.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,99 @@
package commons

import (
"fmt"
"testing"
)

func TestParseSchemaVersion(t *testing.T) {
tests := []struct {
input string
want SchemaVersion
wantErr bool
}{
{"1.2", SchemaVersion{1, 2, "1.2"}, false},
{"2.0", SchemaVersion{2, 0, "2.0"}, false},
{"0.1", SchemaVersion{0, 1, "0.1"}, false},
{"10.20", SchemaVersion{10, 20, "10.20"}, false},
{"", SchemaVersion{}, true},
{"abc", SchemaVersion{}, true},
{"1", SchemaVersion{}, true},
{"1.2.3", SchemaVersion{1, 2, "1.2.3"}, true}, // we only want MAJOR.MINOR
{"a.b", SchemaVersion{}, true},
{"1.b", SchemaVersion{}, true},
}

for _, tt := range tests {
t.Run(tt.input, func(t *testing.T) {
got, err := ParseSchemaVersion(tt.input)
if (err != nil) != tt.wantErr {
t.Errorf("ParseSchemaVersion(%q) error = %v, wantErr %v", tt.input, err, tt.wantErr)
return
}
if err != nil {
return
}
if got.Major != tt.want.Major || got.Minor != tt.want.Minor {
t.Errorf("ParseSchemaVersion(%q) = %v, want %v", tt.input, got, tt.want)
}
})
}
}

func TestParseSchemaVersion_123(t *testing.T) {
// "1.2.3" should fail since SplitN with n=2 produces ["1", "2.3"]
// and "2.3" is not a valid integer for MINOR.
_, err := ParseSchemaVersion("1.2.3")
if err == nil {
t.Error("expected error for 1.2.3 but got nil")
}
}

func TestCompareVersions(t *testing.T) {
tests := []struct {
name string
local SchemaVersion
upstream SchemaVersion
want VersionDelta
}{
{"same", sv(1, 2), sv(1, 2), VersionSame},
{"minor bump", sv(1, 1), sv(1, 2), VersionMinor},
{"major bump", sv(1, 2), sv(2, 0), VersionMajor},
{"local ahead minor", sv(1, 3), sv(1, 2), VersionAhead},
{"local ahead major", sv(2, 0), sv(1, 5), VersionAhead},
{"major bump with minor", sv(1, 5), sv(2, 3), VersionMajor},
{"zero to one", sv(0, 1), sv(1, 0), VersionMajor},
}

for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
got := CompareVersions(tt.local, tt.upstream)
if got != tt.want {
t.Errorf("CompareVersions(%v, %v) = %d, want %d", tt.local, tt.upstream, got, tt.want)
}
})
}
}

func TestSchemaVersionString(t *testing.T) {
v := SchemaVersion{Major: 1, Minor: 2, Raw: "1.2"}
if s := v.String(); s != "1.2" {
t.Errorf("String() = %q, want %q", s, "1.2")
}
}

func TestSchemaVersionIsZero(t *testing.T) {
zero := SchemaVersion{}
if !zero.IsZero() {
t.Error("expected zero value to be IsZero()")
}
nonZero := SchemaVersion{Major: 1, Minor: 0, Raw: "1.0"}
if nonZero.IsZero() {
t.Error("expected non-zero value to not be IsZero()")
}
}

// sv is a test helper to create a SchemaVersion.
func sv(major, minor int) SchemaVersion {
return SchemaVersion{Major: major, Minor: minor, Raw: fmt.Sprintf("%d.%d", major, minor)}
}
4 changes: 2 additions & 2 deletions test/integration/commons_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -301,7 +301,7 @@ func TestCommonsData_MetaVersion(t *testing.T) {
}

version := rows[1][0]
if version != "1.1" {
t.Errorf("schema_version = %q, want %q", version, "1.1")
if version != "1.2" {
t.Errorf("schema_version = %q, want %q", version, "1.2")
}
}
Loading