Skip to content

Commit 89c1379

Browse files
gustavobertoiclaude
andcommitted
feat(ws): X3 — postPull hooks on ws sync (spec 11)
`ws sync` now runs each project's `postPull` hooks when a fast-forward pull actually advances HEAD — the "I pulled new code, re-run setup" trigger. - `git.Head` (rev-parse HEAD) detects whether the pull moved the worktree; the SHA change IS the trigger, so no state ledger is needed (postPull runs exactly once per pulled revision; re-syncing with no new commits is a no-op). - Hooks come from each project's devstack.yaml; the full model is loaded best-effort, so an uncloned sibling degrades to git-only sync (with a [note]), never a hard failure. `--no-hooks` skips them. - Host hooks run in the repo dir; compose-exec hooks target the project stack if its generated compose exists. Default onFailure is `warn` so a broken setup hook can't trap a sync; an explicit `onFailure: abort` surfaces as a repo failure in the summary. Unit-tested: `git.Head` tracks commits; `runPostPull` executes host hooks in the repo dir, propagates an abort, and defaults to warn. Remaining X3: firstRun hooks (blocked on the provision phase's scope_key). Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
1 parent 498b619 commit 89c1379

3 files changed

Lines changed: 159 additions & 3 deletions

File tree

‎internal/cli/postpull_test.go‎

Lines changed: 86 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,86 @@
1+
package cli
2+
3+
import (
4+
"context"
5+
"os"
6+
"os/exec"
7+
"path/filepath"
8+
"testing"
9+
10+
"github.com/open-source-cloud/devstack/internal/config"
11+
"github.com/open-source-cloud/devstack/internal/git"
12+
)
13+
14+
func TestGitHeadTracksCommits(t *testing.T) {
15+
requireGit(t)
16+
dir := t.TempDir()
17+
gitInitRepo(t, dir)
18+
gx, err := git.New()
19+
if err != nil {
20+
t.Fatal(err)
21+
}
22+
ctx := context.Background()
23+
h1, err := gx.Head(ctx, dir)
24+
if err != nil || len(h1) < 7 {
25+
t.Fatalf("Head = %q, err %v", h1, err)
26+
}
27+
// A new commit moves HEAD.
28+
if err := os.WriteFile(filepath.Join(dir, "f"), []byte("y\n"), 0o644); err != nil {
29+
t.Fatal(err)
30+
}
31+
for _, args := range [][]string{{"add", "-A"}, {"commit", "-q", "-m", "two"}} {
32+
c := exec.Command("git", args...)
33+
c.Dir = dir
34+
c.Env = append(os.Environ(), "GIT_TERMINAL_PROMPT=0", "LC_ALL=C")
35+
if out, err := c.CombinedOutput(); err != nil {
36+
t.Fatalf("git %v: %v\n%s", args, err, out)
37+
}
38+
}
39+
h2, err := gx.Head(ctx, dir)
40+
if err != nil {
41+
t.Fatal(err)
42+
}
43+
if h2 == h1 {
44+
t.Error("HEAD should change after a new commit")
45+
}
46+
}
47+
48+
func TestRunPostPullExecutesHostHooks(t *testing.T) {
49+
dir := t.TempDir()
50+
r := repo{name: "app", dir: dir}
51+
hook := config.Hook{
52+
Name: "setup",
53+
Run: "host",
54+
Command: []string{"sh", "-c", "echo ran > postpull.marker"},
55+
}
56+
if err := runPostPull(context.Background(), r, "deadbeef", []config.Hook{hook}); err != nil {
57+
t.Fatalf("runPostPull: %v", err)
58+
}
59+
if _, err := os.Stat(filepath.Join(dir, "postpull.marker")); err != nil {
60+
t.Errorf("postPull host hook did not run in the repo dir: %v", err)
61+
}
62+
}
63+
64+
func TestRunPostPullAbortPropagates(t *testing.T) {
65+
dir := t.TempDir()
66+
r := repo{name: "app", dir: dir}
67+
hook := config.Hook{
68+
Name: "boom",
69+
Run: "host",
70+
Command: []string{"sh", "-c", "exit 7"},
71+
OnFailure: "abort",
72+
}
73+
if err := runPostPull(context.Background(), r, "sha", []config.Hook{hook}); err == nil {
74+
t.Fatal("an onFailure:abort postPull hook must surface an error")
75+
}
76+
}
77+
78+
func TestRunPostPullDefaultsToWarn(t *testing.T) {
79+
// With no onFailure, a failing postPull hook defaults to warn → sync still ok.
80+
dir := t.TempDir()
81+
r := repo{name: "app", dir: dir}
82+
hook := config.Hook{Name: "soft", Run: "host", Command: []string{"sh", "-c", "exit 1"}}
83+
if err := runPostPull(context.Background(), r, "sha", []config.Hook{hook}); err != nil {
84+
t.Errorf("default onFailure should be warn (no error), got %v", err)
85+
}
86+
}

‎internal/cli/ws.go‎

Lines changed: 63 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -13,7 +13,9 @@ import (
1313
"golang.org/x/sync/errgroup"
1414

1515
"github.com/open-source-cloud/devstack/internal/config"
16+
"github.com/open-source-cloud/devstack/internal/generate"
1617
"github.com/open-source-cloud/devstack/internal/git"
18+
"github.com/open-source-cloud/devstack/internal/hooks"
1719
)
1820

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

261263
func newWsSyncCmd(g *GlobalOpts) *cobra.Command {
262-
var jobs int
264+
var (
265+
jobs int
266+
noHooks bool
267+
)
263268
cmd := &cobra.Command{
264269
Use: "sync [names...]",
265-
Short: "Fetch + fast-forward pull every repo in parallel",
270+
Short: "Fetch + fast-forward pull every repo in parallel (runs postPull hooks on new commits)",
266271
RunE: func(cmd *cobra.Command, args []string) error {
267272
repos, err := loadRepos(args)
268273
if err != nil {
@@ -275,22 +280,77 @@ func newWsSyncCmd(g *GlobalOpts) *cobra.Command {
275280
if jobs <= 0 {
276281
jobs = defaultJobs()
277282
}
283+
// postPull hooks come from each project's devstack.yaml. Load the full
284+
// model best-effort: if a sibling repo isn't cloned yet the load fails,
285+
// so we degrade to git-only sync (postPull just doesn't fire).
286+
postPull := map[string][]config.Hook{}
287+
if !noHooks {
288+
if m, lerr := config.Load(mustCwd()); lerr == nil {
289+
for name, p := range m.Projects {
290+
if len(p.Hooks.PostPull) > 0 {
291+
postPull[name] = p.Hooks.PostPull
292+
}
293+
}
294+
} else if !g.Quiet {
295+
fmt.Fprintf(cmd.ErrOrStderr(), "[note] postPull hooks skipped: %v\n", lerr)
296+
}
297+
}
298+
278299
results := runPerRepo(cmd.Context(), repos, jobs, func(ctx context.Context, r repo) error {
279300
if !gx.IsRepo(ctx, r.dir) {
280301
return fmt.Errorf("not cloned (run `ws clone`)")
281302
}
303+
before, _ := gx.Head(ctx, r.dir) // "" if it can't be read; treated as changed
282304
if err := gx.Fetch(ctx, r.dir); err != nil {
283305
return err
284306
}
285-
return gx.Pull(ctx, r.dir)
307+
if err := gx.Pull(ctx, r.dir); err != nil {
308+
return err
309+
}
310+
after, _ := gx.Head(ctx, r.dir)
311+
// postPull fires only when the pull advanced HEAD (spec 11): the
312+
// SHA change IS the trigger, so no ledger is needed here.
313+
if hooks := postPull[r.name]; len(hooks) > 0 && after != before {
314+
return runPostPull(ctx, r, after, hooks)
315+
}
316+
return nil
286317
})
287318
return reportResults(cmd, g, "sync", results)
288319
},
289320
}
290321
cmd.Flags().IntVar(&jobs, "jobs", 0, "max parallel syncs (default min(8, 2*CPUs))")
322+
cmd.Flags().BoolVar(&noHooks, "no-hooks", false, "skip postPull hooks")
291323
return cmd
292324
}
293325

326+
// runPostPull runs a project's postPull hooks after its worktree advanced to head.
327+
// Host hooks run in the repo dir; compose-exec hooks target the project stack if
328+
// its generated compose exists. No state ledger: the HEAD change is the trigger,
329+
// so hooks run exactly once per pulled revision. preDown-style warn default keeps
330+
// a broken setup hook from failing the whole sync.
331+
func runPostPull(ctx context.Context, r repo, head string, hookList []config.Hook) error {
332+
composeFile := filepath.Join(r.dir, generate.GenDir, generate.ComposeFile)
333+
if _, err := os.Stat(composeFile); err != nil {
334+
composeFile = ""
335+
}
336+
runner := &hooks.Runner{
337+
Execer: hooks.OSExecer{BaseDir: r.dir, Project: "devstack-" + r.name, File: composeFile},
338+
}
339+
_, err := runner.RunPhase(ctx, hookList, hooks.PhaseOpts{
340+
Project: r.name,
341+
Phase: "postPull",
342+
DefaultOnFailure: hooks.OnWarn,
343+
ScopeKey: func(config.Hook) string { return head }, // satisfies `once:` hooks (no ledger → runs)
344+
})
345+
return err
346+
}
347+
348+
// mustCwd returns the working directory (empty on error — callers degrade).
349+
func mustCwd() string {
350+
cwd, _ := os.Getwd()
351+
return cwd
352+
}
353+
294354
// --- ws git ----------------------------------------------------------------
295355

296356
func newWsGitCmd(g *GlobalOpts) *cobra.Command {

‎internal/git/gitx.go‎

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -183,6 +183,16 @@ func (g *Git) Pull(ctx context.Context, dir string) error {
183183
return err
184184
}
185185

186+
// Head returns the current commit SHA (HEAD). Used to detect whether a pull
187+
// actually advanced the worktree (the postPull-hook trigger, spec 11).
188+
func (g *Git) Head(ctx context.Context, dir string) (string, error) {
189+
out, err := g.run(ctx, dir, "--no-optional-locks", "rev-parse", "HEAD")
190+
if err != nil {
191+
return "", err
192+
}
193+
return strings.TrimSpace(string(out)), nil
194+
}
195+
186196
// RemoteURL returns origin's URL (for idempotent clone validation).
187197
func (g *Git) RemoteURL(ctx context.Context, dir string) (string, error) {
188198
out, err := g.run(ctx, dir, "--no-optional-locks", "remote", "get-url", "origin")

0 commit comments

Comments
 (0)