Skip to content

Commit 96a8f0f

Browse files
authored
hotfix: preserve earlier-exercise code when resetting, syncing, or replacing on merge conflict (#49)
1 parent 21efdca commit 96a8f0f

8 files changed

Lines changed: 501 additions & 194 deletions

File tree

trainings/exercise_replace.go

Lines changed: 95 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,95 @@
1+
package trainings
2+
3+
import (
4+
"fmt"
5+
6+
"github.com/spf13/afero"
7+
8+
"github.com/ThreeDotsLabs/cli/trainings/files"
9+
"github.com/ThreeDotsLabs/cli/trainings/genproto"
10+
"github.com/ThreeDotsLabs/cli/trainings/git"
11+
)
12+
13+
// replaceExerciseFiles writes the given files into exerciseDir and DELETES any
14+
// working-tree file in that dir not in the list.
15+
//
16+
// INVARIANT: after this call, exerciseDir is 1:1 with `files` — no stale user
17+
// content. This is the SINGLE POINT that enforces the "sync/replace must not
18+
// leave stale user files" invariant. Every code path that replaces the user's
19+
// solution with example or start-state files MUST route through this function
20+
// (via replaceExerciseFilesAndCommit).
21+
//
22+
// Do NOT inline this call with files.NewFilesSilent — that path is additive and
23+
// will reintroduce the project-style-training staleness bug (empty placeholders
24+
// from earlier scaffolds overwriting the user's filled-in work; see the
25+
// 0001_init_orders.up.sql regression).
26+
func replaceExerciseFiles(
27+
trainingRootFs *afero.BasePathFs,
28+
replacementFiles []*genproto.File,
29+
exerciseDir string,
30+
) error {
31+
return files.NewFilesSilentDeleteUnused().WriteExerciseFiles(replacementFiles, trainingRootFs, exerciseDir)
32+
}
33+
34+
// mergeStartStateFiles returns the starting state of an exercise:
35+
// golden(prev) with scaffold(current) overlaid on top. Scaffold wins on path
36+
// collisions because it represents the delta from end-of-prev to start-of-current.
37+
// Pass nil goldenFiles for the first exercise.
38+
func mergeStartStateFiles(
39+
goldenFiles []*genproto.File,
40+
scaffoldFiles []*genproto.File,
41+
) []*genproto.File {
42+
byPath := make(map[string]*genproto.File, len(goldenFiles)+len(scaffoldFiles))
43+
for _, f := range goldenFiles {
44+
byPath[f.Path] = f
45+
}
46+
for _, f := range scaffoldFiles {
47+
byPath[f.Path] = f // scaffold wins on collisions
48+
}
49+
merged := make([]*genproto.File, 0, len(byPath))
50+
for _, f := range byPath {
51+
merged = append(merged, f)
52+
}
53+
return merged
54+
}
55+
56+
// replaceExerciseFilesAndCommit is the complete orchestration for replacing
57+
// the user's exercise files with a given file list: save backup → write files
58+
// (1:1, deletes extras) → stage → commit. ALL callers that replace the user's
59+
// solution with example / start-state content MUST go through this function:
60+
// - overrideWithGolden ('s' action): files = golden(current)
61+
// - g during next/merge-conflict: files = golden(prev) + scaffold(current)
62+
// - resetCleanFiles: files = golden(prev) + scaffold(current)
63+
//
64+
// The backup branch is REQUIRED — destructive ops must always be recoverable.
65+
// If saveToBackupBranch returns errBackupAborted, that error is returned directly
66+
// so callers can distinguish user-abort from other failures.
67+
//
68+
// Returns (committed=true, nil) on success with a commit created, (false, nil)
69+
// if the resulting state matched HEAD with nothing to commit, or (false, err)
70+
// on any failure.
71+
func replaceExerciseFilesAndCommit(
72+
gitOps *git.Ops,
73+
trainingRootFs *afero.BasePathFs,
74+
replacementFiles []*genproto.File,
75+
exerciseDir string,
76+
backupBranch string,
77+
commitMsg string,
78+
) (committed bool, err error) {
79+
if err := saveToBackupBranch(gitOps, backupBranch); err != nil {
80+
return false, err
81+
}
82+
if err := replaceExerciseFiles(trainingRootFs, replacementFiles, exerciseDir); err != nil {
83+
return false, fmt.Errorf("could not replace exercise files: %w", err)
84+
}
85+
if err := gitOps.AddAll(exerciseDir); err != nil {
86+
return false, fmt.Errorf("could not stage replaced files: %w", err)
87+
}
88+
if !gitOps.HasStagedChanges() {
89+
return false, nil
90+
}
91+
if err := gitOps.Commit(commitMsg); err != nil {
92+
return false, fmt.Errorf("could not commit replaced files: %w", err)
93+
}
94+
return true, nil
95+
}

trainings/exercise_replace_test.go

Lines changed: 115 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,115 @@
1+
package trainings
2+
3+
import (
4+
"sort"
5+
"testing"
6+
7+
"github.com/spf13/afero"
8+
"github.com/stretchr/testify/assert"
9+
"github.com/stretchr/testify/require"
10+
11+
"github.com/ThreeDotsLabs/cli/trainings/genproto"
12+
)
13+
14+
func TestMergeStartStateFiles(t *testing.T) {
15+
t.Run("first exercise (nil golden) returns scaffold as-is", func(t *testing.T) {
16+
scaffold := []*genproto.File{
17+
{Path: "a.txt", Content: "A"},
18+
{Path: "b.txt", Content: "B"},
19+
}
20+
merged := mergeStartStateFiles(nil, scaffold)
21+
assertFilesByPath(t, merged, map[string]string{
22+
"a.txt": "A",
23+
"b.txt": "B",
24+
})
25+
})
26+
27+
t.Run("scaffold wins on path collision", func(t *testing.T) {
28+
golden := []*genproto.File{
29+
{Path: "shared.txt", Content: "from golden"},
30+
{Path: "golden-only.txt", Content: "G"},
31+
}
32+
scaffold := []*genproto.File{
33+
{Path: "shared.txt", Content: "from scaffold"}, // overrides golden
34+
{Path: "scaffold-only.txt", Content: "S"},
35+
}
36+
merged := mergeStartStateFiles(golden, scaffold)
37+
assertFilesByPath(t, merged, map[string]string{
38+
"shared.txt": "from scaffold",
39+
"golden-only.txt": "G",
40+
"scaffold-only.txt": "S",
41+
})
42+
})
43+
44+
t.Run("regression: golden with filled-in placeholder is preserved when scaffold does not redeliver it", func(t *testing.T) {
45+
// This is the 0001_init_orders.up.sql scenario.
46+
// Earlier exercises scaffolded the file as empty; the user filled it in.
47+
// By a later exercise, the scaffold no longer includes that file — only
48+
// the prev-exercise golden does. The start state must preserve the
49+
// filled-in content.
50+
golden := []*genproto.File{
51+
{Path: "migrations/0001_init.sql", Content: "CREATE TABLE ..."},
52+
{Path: "common.go", Content: "package common"},
53+
}
54+
scaffold := []*genproto.File{
55+
{Path: "new_for_this_exercise.go", Content: "package new"},
56+
}
57+
merged := mergeStartStateFiles(golden, scaffold)
58+
assertFilesByPath(t, merged, map[string]string{
59+
"migrations/0001_init.sql": "CREATE TABLE ...", // survives from golden
60+
"common.go": "package common",
61+
"new_for_this_exercise.go": "package new",
62+
})
63+
})
64+
}
65+
66+
func TestReplaceExerciseFiles_is1to1(t *testing.T) {
67+
// The invariant: after replaceExerciseFiles, exerciseDir contains exactly
68+
// the replacement files — any extras are deleted. This is load-bearing
69+
// for the "sync with example" and "replace on conflict" UX: user's project
70+
// must never silently diverge from what the caller asked for.
71+
fs := afero.NewMemMapFs()
72+
rootFs := afero.NewBasePathFs(fs, "/").(*afero.BasePathFs)
73+
74+
// Pre-populate exerciseDir with some files that should be removed.
75+
require.NoError(t, afero.WriteFile(fs, "/ex/stale_placeholder.sql", []byte("-- todo"), 0644))
76+
require.NoError(t, afero.WriteFile(fs, "/ex/user_scratch.txt", []byte("notes"), 0644))
77+
require.NoError(t, afero.WriteFile(fs, "/ex/keep_me.go", []byte("old content"), 0644))
78+
79+
replacement := []*genproto.File{
80+
{Path: "keep_me.go", Content: "new content"},
81+
{Path: "fresh.go", Content: "fresh"},
82+
}
83+
require.NoError(t, replaceExerciseFiles(rootFs, replacement, "ex"))
84+
85+
// keep_me.go is overwritten
86+
got, err := afero.ReadFile(fs, "/ex/keep_me.go")
87+
require.NoError(t, err)
88+
assert.Equal(t, "new content", string(got))
89+
90+
// fresh.go is created
91+
got, err = afero.ReadFile(fs, "/ex/fresh.go")
92+
require.NoError(t, err)
93+
assert.Equal(t, "fresh", string(got))
94+
95+
// stale_placeholder.sql and user_scratch.txt are DELETED (not in replacement).
96+
// This is the 1:1 invariant the fix enforces.
97+
_, err = fs.Stat("/ex/stale_placeholder.sql")
98+
assert.True(t, err != nil, "stale placeholder should have been deleted")
99+
_, err = fs.Stat("/ex/user_scratch.txt")
100+
assert.True(t, err != nil, "user scratch should have been deleted")
101+
}
102+
103+
func assertFilesByPath(t *testing.T, got []*genproto.File, want map[string]string) {
104+
t.Helper()
105+
byPath := map[string]string{}
106+
for _, f := range got {
107+
byPath[f.Path] = f.Content
108+
}
109+
paths := make([]string, 0, len(byPath))
110+
for p := range byPath {
111+
paths = append(paths, p)
112+
}
113+
sort.Strings(paths)
114+
assert.Equal(t, want, byPath, "merged files mismatch; paths present: %v", paths)
115+
}

trainings/exercises.go

Lines changed: 71 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,71 @@
1+
package trainings
2+
3+
import (
4+
"context"
5+
"fmt"
6+
7+
"github.com/ThreeDotsLabs/cli/trainings/genproto"
8+
)
9+
10+
// fetchGoldenFiles wraps GetGoldenSolution with the standard error path.
11+
// Used by overrideWithGolden ('s'), g handlers, and reset — every caller that
12+
// needs the example-solution file list for a specific exercise.
13+
func (h *Handlers) fetchGoldenFiles(
14+
ctx context.Context,
15+
trainingName, exerciseID, token string,
16+
) ([]*genproto.File, error) {
17+
resp, err := h.newGrpcClient().GetGoldenSolution(ctx, &genproto.GetGoldenSolutionRequest{
18+
TrainingName: trainingName,
19+
ExerciseId: exerciseID,
20+
Token: token,
21+
})
22+
if err != nil {
23+
return nil, fmt.Errorf("failed to fetch example solution for exercise %s: %w", exerciseID, err)
24+
}
25+
return resp.Files, nil
26+
}
27+
28+
// resolvePreviousExercise returns the ID and module/exercise path of the exercise
29+
// immediately preceding currentExerciseID in the training's order. Returns
30+
// ("", "", nil) when currentExerciseID is the first exercise.
31+
//
32+
// One GetExercises call per invocation — callers may cache the result per-command
33+
// if they need it more than once, but the common case (single reset / single 'g')
34+
// only needs it once.
35+
func (h *Handlers) resolvePreviousExercise(
36+
ctx context.Context,
37+
trainingName, token, currentExerciseID string,
38+
) (prevExerciseID, prevModuleExercisePath string, err error) {
39+
resp, err := h.newGrpcClient().GetExercises(ctx, &genproto.GetExercisesRequest{
40+
TrainingName: trainingName,
41+
Token: token,
42+
})
43+
if err != nil {
44+
return "", "", fmt.Errorf("failed to list exercises: %w", err)
45+
}
46+
47+
type flatExercise struct {
48+
id string
49+
moduleExercisePath string
50+
}
51+
var flat []flatExercise
52+
for _, module := range resp.Modules {
53+
for _, exercise := range module.Exercises {
54+
flat = append(flat, flatExercise{
55+
id: exercise.Id,
56+
moduleExercisePath: module.Name + "/" + exercise.Name,
57+
})
58+
}
59+
}
60+
61+
for i, e := range flat {
62+
if e.id == currentExerciseID {
63+
if i == 0 {
64+
return "", "", nil // first exercise — no predecessor
65+
}
66+
return flat[i-1].id, flat[i-1].moduleExercisePath, nil
67+
}
68+
}
69+
70+
return "", "", fmt.Errorf("exercise %s not found in training %s", currentExerciseID, trainingName)
71+
}

trainings/files/files.go

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -65,6 +65,13 @@ func NewFilesSilent() Files {
6565
// NewFilesSilentDeleteUnused creates a Files that writes silently and deletes
6666
// files not present in the server response. Use for override operations where
6767
// the exercise directory should exactly match the example solution.
68+
//
69+
// Used by trainings.replaceExerciseFiles to enforce the "1:1 with the replacement
70+
// file list" invariant on "sync with example" and "replace on merge conflict"
71+
// flows. Do NOT switch the replace primitive to NewFilesSilent (additive) — that
72+
// path allowed stale empty placeholders from earlier scaffolds to overwrite
73+
// filled-in user code in project-style trainings (the 0001_init_orders.up.sql
74+
// regression).
6875
func NewFilesSilentDeleteUnused() Files {
6976
return Files{
7077
forceOverwrite: true,

trainings/jump.go

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -305,13 +305,13 @@ func (h *Handlers) Jump(ctx context.Context, exerciseID string) error {
305305
case '\n':
306306
// Continue — no file ops, just update config pointer
307307
case 'r':
308-
if _, err := h.resetCleanFiles(gitOps, initBranch, moduleExercisePath, resp.Dir); err != nil {
308+
if _, err := h.resetCleanFiles(ctx, gitOps, trainingRootFs, resp.ExerciseId, moduleExercisePath, resp.Dir); err != nil {
309309
if errors.Is(err, errBackupAborted) {
310310
return nil // user aborted — clean exit
311311
}
312312
return err
313313
}
314-
writeServerFiles(resp.FilesToCreate, trainingRootFs, resp.Dir, gitOps, moduleExercisePath)
314+
// resetCleanFiles already fetched fresh scaffold+golden and wrote the start state.
315315
case 's':
316316
if err := h.checkoutSolution(ctx, gitOps, successfulVerificationId, moduleExercisePath, trainingRootFs); err != nil {
317317
if errors.Is(err, errBackupAborted) {
@@ -323,7 +323,7 @@ func (h *Handlers) Jump(ctx context.Context, exerciseID string) error {
323323
}
324324
}
325325

326-
_, err = h.setExercise(trainingRootFs, resp, trainingRoot, false)
326+
_, err = h.setExercise(ctx, trainingRootFs, resp, trainingRoot, false)
327327
return err
328328
}
329329

0 commit comments

Comments
 (0)