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
28 changes: 27 additions & 1 deletion .github/workflows/ci.yml
Original file line number Diff line number Diff line change
Expand Up @@ -103,6 +103,32 @@ jobs:
version: "~> v2"
args: release --snapshot --clean

# Native macOS arm64 lane (G2): proves the darwin/arm64 RUNTIME target — not just
# the cross-compile on the Linux lane — actually builds and passes its daemon-free
# tests. Hosted macOS runners have no Docker, so the integration/e2e (daemon)
# steps stay on the ubuntu `ci` lane; this lane runs build + unit(-race) + a
# binary preflight. Invoked via `go` directly (not make: hosted macOS ships BSD
# make, and the Makefile uses GNU features).
macos:
runs-on: macos-14
steps:
- uses: actions/checkout@v4
- uses: actions/setup-go@v5
with:
go-version: ${{ env.GO_VERSION }}
check-latest: true
cache: true
- name: build (CGO disabled — static binary invariant)
run: CGO_ENABLED=0 go build ./...
- name: unit tests -race
env:
CGO_ENABLED: "1" # the race detector requires cgo
run: go test -race ./...
- name: binary preflight (runs natively on arm64)
run: |
go build -o devstack ./cmd/devstack
./devstack version
./devstack --help >/dev/null

# Placeholder lanes wired as their milestones land:
# - macos: macos-14 arm64 preflight-only (G2)
# - config-conformance: golden workspace exercising every schema field
86 changes: 86 additions & 0 deletions internal/cli/postpull_test.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,86 @@
package cli

import (
"context"
"os"
"os/exec"
"path/filepath"
"testing"

"github.com/open-source-cloud/devstack/internal/config"
"github.com/open-source-cloud/devstack/internal/git"
)

func TestGitHeadTracksCommits(t *testing.T) {
requireGit(t)
dir := t.TempDir()
gitInitRepo(t, dir)
gx, err := git.New()
if err != nil {
t.Fatal(err)
}
ctx := context.Background()
h1, err := gx.Head(ctx, dir)
if err != nil || len(h1) < 7 {
t.Fatalf("Head = %q, err %v", h1, err)
}
// A new commit moves HEAD.
if err := os.WriteFile(filepath.Join(dir, "f"), []byte("y\n"), 0o644); err != nil {
t.Fatal(err)
}
for _, args := range [][]string{{"add", "-A"}, {"commit", "-q", "-m", "two"}} {
c := exec.Command("git", args...)
c.Dir = dir
c.Env = append(os.Environ(), "GIT_TERMINAL_PROMPT=0", "LC_ALL=C")
if out, err := c.CombinedOutput(); err != nil {
t.Fatalf("git %v: %v\n%s", args, err, out)
}
}
h2, err := gx.Head(ctx, dir)
if err != nil {
t.Fatal(err)
}
if h2 == h1 {
t.Error("HEAD should change after a new commit")
}
}

func TestRunPostPullExecutesHostHooks(t *testing.T) {
dir := t.TempDir()
r := repo{name: "app", dir: dir}
hook := config.Hook{
Name: "setup",
Run: "host",
Command: []string{"sh", "-c", "echo ran > postpull.marker"},
}
if err := runPostPull(context.Background(), r, "deadbeef", []config.Hook{hook}); err != nil {
t.Fatalf("runPostPull: %v", err)
}
if _, err := os.Stat(filepath.Join(dir, "postpull.marker")); err != nil {
t.Errorf("postPull host hook did not run in the repo dir: %v", err)
}
}

func TestRunPostPullAbortPropagates(t *testing.T) {
dir := t.TempDir()
r := repo{name: "app", dir: dir}
hook := config.Hook{
Name: "boom",
Run: "host",
Command: []string{"sh", "-c", "exit 7"},
OnFailure: "abort",
}
if err := runPostPull(context.Background(), r, "sha", []config.Hook{hook}); err == nil {
t.Fatal("an onFailure:abort postPull hook must surface an error")
}
}

func TestRunPostPullDefaultsToWarn(t *testing.T) {
// With no onFailure, a failing postPull hook defaults to warn → sync still ok.
dir := t.TempDir()
r := repo{name: "app", dir: dir}
hook := config.Hook{Name: "soft", Run: "host", Command: []string{"sh", "-c", "exit 1"}}
if err := runPostPull(context.Background(), r, "sha", []config.Hook{hook}); err != nil {
t.Errorf("default onFailure should be warn (no error), got %v", err)
}
}
66 changes: 63 additions & 3 deletions internal/cli/ws.go
Original file line number Diff line number Diff line change
Expand Up @@ -13,7 +13,9 @@ import (
"golang.org/x/sync/errgroup"

"github.com/open-source-cloud/devstack/internal/config"
"github.com/open-source-cloud/devstack/internal/generate"
"github.com/open-source-cloud/devstack/internal/git"
"github.com/open-source-cloud/devstack/internal/hooks"
)

// repo is one workspace repository to manage.
Expand Down Expand Up @@ -259,10 +261,13 @@ func newWsCloneCmd(g *GlobalOpts) *cobra.Command {
// --- ws sync ---------------------------------------------------------------

func newWsSyncCmd(g *GlobalOpts) *cobra.Command {
var jobs int
var (
jobs int
noHooks bool
)
cmd := &cobra.Command{
Use: "sync [names...]",
Short: "Fetch + fast-forward pull every repo in parallel",
Short: "Fetch + fast-forward pull every repo in parallel (runs postPull hooks on new commits)",
RunE: func(cmd *cobra.Command, args []string) error {
repos, err := loadRepos(args)
if err != nil {
Expand All @@ -275,22 +280,77 @@ func newWsSyncCmd(g *GlobalOpts) *cobra.Command {
if jobs <= 0 {
jobs = defaultJobs()
}
// postPull hooks come from each project's devstack.yaml. Load the full
// model best-effort: if a sibling repo isn't cloned yet the load fails,
// so we degrade to git-only sync (postPull just doesn't fire).
postPull := map[string][]config.Hook{}
if !noHooks {
if m, lerr := config.Load(mustCwd()); lerr == nil {
for name, p := range m.Projects {
if len(p.Hooks.PostPull) > 0 {
postPull[name] = p.Hooks.PostPull
}
}
} else if !g.Quiet {
fmt.Fprintf(cmd.ErrOrStderr(), "[note] postPull hooks skipped: %v\n", lerr)
}
}

results := runPerRepo(cmd.Context(), repos, jobs, func(ctx context.Context, r repo) error {
if !gx.IsRepo(ctx, r.dir) {
return fmt.Errorf("not cloned (run `ws clone`)")
}
before, _ := gx.Head(ctx, r.dir) // "" if it can't be read; treated as changed
if err := gx.Fetch(ctx, r.dir); err != nil {
return err
}
return gx.Pull(ctx, r.dir)
if err := gx.Pull(ctx, r.dir); err != nil {
return err
}
after, _ := gx.Head(ctx, r.dir)
// postPull fires only when the pull advanced HEAD (spec 11): the
// SHA change IS the trigger, so no ledger is needed here.
if hooks := postPull[r.name]; len(hooks) > 0 && after != before {
return runPostPull(ctx, r, after, hooks)
}
return nil
})
return reportResults(cmd, g, "sync", results)
},
}
cmd.Flags().IntVar(&jobs, "jobs", 0, "max parallel syncs (default min(8, 2*CPUs))")
cmd.Flags().BoolVar(&noHooks, "no-hooks", false, "skip postPull hooks")
return cmd
}

// runPostPull runs a project's postPull hooks after its worktree advanced to head.
// Host hooks run in the repo dir; compose-exec hooks target the project stack if
// its generated compose exists. No state ledger: the HEAD change is the trigger,
// so hooks run exactly once per pulled revision. preDown-style warn default keeps
// a broken setup hook from failing the whole sync.
func runPostPull(ctx context.Context, r repo, head string, hookList []config.Hook) error {
composeFile := filepath.Join(r.dir, generate.GenDir, generate.ComposeFile)
if _, err := os.Stat(composeFile); err != nil {
composeFile = ""
}
runner := &hooks.Runner{
Execer: hooks.OSExecer{BaseDir: r.dir, Project: "devstack-" + r.name, File: composeFile},
}
_, err := runner.RunPhase(ctx, hookList, hooks.PhaseOpts{
Project: r.name,
Phase: "postPull",
DefaultOnFailure: hooks.OnWarn,
ScopeKey: func(config.Hook) string { return head }, // satisfies `once:` hooks (no ledger → runs)
})
return err
}

// mustCwd returns the working directory (empty on error — callers degrade).
func mustCwd() string {
cwd, _ := os.Getwd()
return cwd
}

// --- ws git ----------------------------------------------------------------

func newWsGitCmd(g *GlobalOpts) *cobra.Command {
Expand Down
10 changes: 10 additions & 0 deletions internal/git/gitx.go
Original file line number Diff line number Diff line change
Expand Up @@ -183,6 +183,16 @@ func (g *Git) Pull(ctx context.Context, dir string) error {
return err
}

// Head returns the current commit SHA (HEAD). Used to detect whether a pull
// actually advanced the worktree (the postPull-hook trigger, spec 11).
func (g *Git) Head(ctx context.Context, dir string) (string, error) {
out, err := g.run(ctx, dir, "--no-optional-locks", "rev-parse", "HEAD")
if err != nil {
return "", err
}
return strings.TrimSpace(string(out)), nil
}

// RemoteURL returns origin's URL (for idempotent clone validation).
func (g *Git) RemoteURL(ctx context.Context, dir string) (string, error) {
out, err := g.run(ctx, dir, "--no-optional-locks", "remote", "get-url", "origin")
Expand Down
Loading