Skip to content

Commit c4f8701

Browse files
committed
fix: Addressing review feedback
1 parent 0c50896 commit c4f8701

7 files changed

Lines changed: 172 additions & 32 deletions

File tree

internal/cli/commands/catalog/tui/redesign/copy.go

Lines changed: 109 additions & 18 deletions
Original file line numberDiff line numberDiff line change
@@ -43,35 +43,58 @@ func (c *CopyCmd) Run() error {
4343

4444
c.logger.Debugf("Copying component %q to %q", src, dst)
4545

46-
if err := copyDir(src, dst); err != nil {
46+
// Preflight: refuse before writing anything if any target file would
47+
// collide with something already in the working directory. Without this,
48+
// a mid-walk collision could leave the working tree in a half-copied
49+
// state.
50+
if err := preflightCopy(src, dst); err != nil {
4751
return err
4852
}
4953

50-
c.result.workingDir = dst
51-
5254
configName := configFileForKind(c.component.Kind)
53-
if configName == "" {
54-
return nil
55+
56+
var (
57+
refs ValuesReferences
58+
hasRefs bool
59+
)
60+
61+
if configName != "" {
62+
refs, err = CollectValuesReferences(filepath.Join(src, configName))
63+
if err != nil {
64+
return err
65+
}
66+
67+
hasRefs = !refs.IsEmpty()
68+
69+
// Also preflight the values stub destination so we can fail before
70+
// copying when a stub would be written but the destination has an
71+
// unrelated obstruction (e.g. it exists as a directory).
72+
if hasRefs {
73+
if err := preflightValuesStub(dst); err != nil {
74+
return err
75+
}
76+
}
5577
}
5678

57-
refs, err := CollectValuesReferences(filepath.Join(src, configName))
58-
if err != nil {
79+
if err := copyDir(src, dst); err != nil {
5980
return err
6081
}
6182

62-
if refs.IsEmpty() {
63-
return nil
64-
}
83+
result := copyResult{workingDir: dst}
6584

66-
c.result.references = refs
85+
if hasRefs {
86+
result.references = refs
6787

68-
written, err := WriteValuesStub(dst, refs)
69-
if err != nil {
70-
return err
88+
written, err := WriteValuesStub(dst, refs)
89+
if err != nil {
90+
return err
91+
}
92+
93+
result.valuesWritten = written
94+
result.valuesSkipped = !written
7195
}
7296

73-
c.result.valuesWritten = written
74-
c.result.valuesSkipped = !written
97+
c.result = result
7598

7699
return nil
77100
}
@@ -82,8 +105,14 @@ func (c *CopyCmd) Result() copyResult {
82105
return c.result
83106
}
84107

85-
func (c *CopyCmd) SetStdin(io.Reader) {}
108+
// SetStdin is a no-op; CopyCmd does not interact with stdio and only
109+
// implements this method to satisfy the tea.ExecCommand interface.
110+
func (c *CopyCmd) SetStdin(io.Reader) {}
111+
112+
// SetStdout is a no-op; see SetStdin.
86113
func (c *CopyCmd) SetStdout(io.Writer) {}
114+
115+
// SetStderr is a no-op; see SetStdin.
87116
func (c *CopyCmd) SetStderr(io.Writer) {}
88117

89118
// resolvePaths returns the absolute source directory (inside the cloned repo)
@@ -157,7 +186,69 @@ func copyDir(src, dst string) error {
157186
})
158187
}
159188

160-
func copyFile(src, dst string) error {
189+
// preflightCopy walks src and returns an error if any non-skipped regular
190+
// file would land on a path that already exists in dst. This makes the copy
191+
// step all-or-nothing for the common collision case, so a half-populated
192+
// working directory cannot result from a mid-walk conflict.
193+
func preflightCopy(src, dst string) error {
194+
return filepath.WalkDir(src, func(path string, d fs.DirEntry, walkErr error) error {
195+
if walkErr != nil {
196+
return walkErr
197+
}
198+
199+
if d.IsDir() {
200+
if path != src && skipDuringCopy(d.Name()) {
201+
return filepath.SkipDir
202+
}
203+
204+
return nil
205+
}
206+
207+
if !d.Type().IsRegular() {
208+
return nil
209+
}
210+
211+
rel, err := filepath.Rel(src, path)
212+
if err != nil {
213+
return errors.New(err)
214+
}
215+
216+
target := filepath.Join(dst, rel)
217+
if _, err := os.Lstat(target); err == nil {
218+
return errors.Errorf("destination %q already exists; refusing to overwrite", target)
219+
} else if !os.IsNotExist(err) {
220+
return errors.New(err)
221+
}
222+
223+
return nil
224+
})
225+
}
226+
227+
// preflightValuesStub returns an error if WriteValuesStub would fail at the
228+
// stub destination for any reason other than a pre-existing values file
229+
// (which it intentionally leaves alone).
230+
func preflightValuesStub(dst string) error {
231+
stub := filepath.Join(dst, valuesFileName)
232+
233+
info, err := os.Lstat(stub)
234+
if err != nil {
235+
if os.IsNotExist(err) {
236+
return nil
237+
}
238+
239+
return errors.New(err)
240+
}
241+
242+
// A regular file at the stub path is fine; WriteValuesStub will leave
243+
// it alone. Anything else (directory, symlink, irregular) blocks us.
244+
if info.Mode().IsRegular() {
245+
return nil
246+
}
247+
248+
return errors.Errorf("destination %q is not a regular file; refusing to overwrite", stub)
249+
}
250+
251+
func copyFile(src, dst string) (err error) {
161252
in, err := os.Open(src)
162253
if err != nil {
163254
return errors.New(err)

internal/cli/commands/catalog/tui/redesign/delegate.go

Lines changed: 9 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -28,17 +28,22 @@ const (
2828
templatePillBgS = "#3A2D55"
2929
templatePillFgS = "#DDC4FA"
3030

31+
// Shared white foreground for the unit and stack pills, whose darker
32+
// backgrounds always read against pure white in both selected and
33+
// unselected states.
34+
pillFgWhite = "#FFFFFF"
35+
3136
// Unit type pill (blue, matching the `list` / `find` command color).
3237
unitPillBg = "#1B46DD"
33-
unitPillFg = "#FFFFFF"
38+
unitPillFg = pillFgWhite
3439
unitPillBgS = "#2E5BEA"
35-
unitPillFgS = "#FFFFFF"
40+
unitPillFgS = pillFgWhite
3641

3742
// Stack type pill (green, matching the `list` / `find` command color).
3843
stackPillBg = "#2E8B57"
39-
stackPillFg = "#FFFFFF"
44+
stackPillFg = pillFgWhite
4045
stackPillBgS = "#3CA068"
41-
stackPillFgS = "#FFFFFF"
46+
stackPillFgS = pillFgWhite
4247

4348
// Version pill (neutral).
4449
versionBg = "#313244"

internal/cli/commands/catalog/tui/redesign/update.go

Lines changed: 32 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -247,7 +247,12 @@ func (m Model) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
247247

248248
case scaffoldFinishedMsg:
249249
if msg.err != nil {
250-
return m, tea.Batch(tea.Printf("error scaffolding component: %s", msg.err.Error()), tea.Quit)
250+
// tea.Printf during alt-screen gets discarded on teardown, so
251+
// stash the failure on the model and let RunRedesign emit it
252+
// to the user's scrollback after exit.
253+
m.exitMessage = formatActionFailure("scaffolding component", msg.err)
254+
255+
return m, tea.Quit
251256
}
252257

253258
// Same post-exit-message pattern as the copy flow: stash a styled
@@ -259,7 +264,9 @@ func (m Model) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
259264

260265
case copyFinishedMsg:
261266
if msg.err != nil {
262-
return m, tea.Batch(tea.Printf("error copying component: %s", msg.err.Error()), tea.Quit)
267+
m.exitMessage = formatActionFailure("copying component", msg.err)
268+
269+
return m, tea.Quit
263270
}
264271

265272
// Stash a styled post-exit message on the model so RunRedesign
@@ -330,10 +337,33 @@ func copyComponentCmd(l log.Logger, m Model, c *Component) tea.Cmd {
330337
const (
331338
valuesBoxAccentGreen = "#50FA7B"
332339
valuesBoxAccentYellow = "#F1FA8C"
340+
valuesBoxAccentRed = "#FF5555"
333341
valuesBoxPathColor = "#8BE9FD"
334342
valuesBoxMutedColor = "#A8ACB1"
335343
)
336344

345+
// formatActionFailure renders a bordered callout describing a failed
346+
// scaffold or copy action. action is a verb phrase ("scaffolding component",
347+
// "copying component"). The message is stashed on the model so RunRedesign
348+
// can print it after the alt screen is restored — tea.Printf lines emitted
349+
// during alt-screen are discarded on exit.
350+
func formatActionFailure(action string, err error) string {
351+
heading := lipgloss.NewStyle().
352+
Foreground(lipgloss.Color(valuesBoxAccentRed)).
353+
Bold(true).
354+
Render("error " + action)
355+
356+
body := err.Error()
357+
358+
content := lipgloss.JoinVertical(lipgloss.Left, heading, "", body)
359+
360+
return lipgloss.NewStyle().
361+
Border(lipgloss.RoundedBorder()).
362+
BorderForeground(lipgloss.Color(valuesBoxAccentRed)).
363+
Padding(bodyPaddingVertical, bodyPaddingHorizontal).
364+
Render(content)
365+
}
366+
337367
var (
338368
valuesBoxPathStyle = lipgloss.NewStyle().Foreground(lipgloss.Color(valuesBoxPathColor))
339369
valuesBoxMuteStyle = lipgloss.NewStyle().Foreground(lipgloss.Color(valuesBoxMutedColor))

internal/cli/commands/catalog/tui/redesign/values.go

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -251,6 +251,9 @@ func WriteValuesStub(dir string, refs ValuesReferences) (bool, error) {
251251

252252
buf.WriteString(valuesStubHeader)
253253

254+
// Sort defensively so callers passing a hand-constructed
255+
// ValuesReferences get deterministic output without needing to know that
256+
// CollectValuesReferences happens to pre-sort.
254257
if len(refs.Required) > 0 {
255258
buf.WriteString(requiredSectionHeader)
256259

internal/cli/commands/catalog/tui/redesign/welcome.go

Lines changed: 3 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -147,11 +147,12 @@ func RunRedesign(ctx context.Context, l log.Logger, opts *options.TerragruntOpti
147147
EmitExitMessage(finalModel, errWriter, l)
148148

149149
if err != nil {
150-
if cause := context.Cause(ctx); errors.Is(cause, context.Canceled) {
150+
cause := context.Cause(ctx)
151+
if errors.Is(cause, context.Canceled) {
151152
return nil
152153
}
153154

154-
if cause := context.Cause(ctx); cause != nil {
155+
if cause != nil {
155156
return cause
156157
}
157158

internal/util/file.go

Lines changed: 8 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -1104,14 +1104,18 @@ func SanitizePath(baseDir string, file string) (sanitized string, err error) {
11041104
}
11051105
}()
11061106

1107-
fileInfo, err := root.Stat(file)
1108-
if err != nil {
1107+
if _, err := root.Stat(file); err != nil {
11091108
return "", err
11101109
}
11111110

1112-
fullPath := baseDir + string(os.PathSeparator) + fileInfo.Name()
1111+
// Preserve nested directories from the validated input. Using
1112+
// fileInfo.Name() would flatten "a/b/c.txt" to "<baseDir>/c.txt".
1113+
// root.Stat already rejects paths that escape baseDir, so we only need
1114+
// to clean the input and join it back onto baseDir.
1115+
cleanedRelative := filepath.Clean(file)
1116+
cleanedRelative = strings.TrimLeft(cleanedRelative, string(os.PathSeparator))
11131117

1114-
return fullPath, nil
1118+
return filepath.Join(baseDir, cleanedRelative), nil
11151119
}
11161120

11171121
// RelPathForLog returns a relative path suitable for logging.

internal/util/file_test.go

Lines changed: 8 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -612,7 +612,13 @@ func Test_sanitizePath(t *testing.T) {
612612
name: "happy path",
613613
baseDir: "./testdata/fixture-sanitize-path/env/unit",
614614
file: ".terraform-version",
615-
want: "./testdata/fixture-sanitize-path/env/unit/.terraform-version",
615+
want: "testdata/fixture-sanitize-path/env/unit/.terraform-version",
616+
},
617+
{
618+
name: "nested file path is preserved",
619+
baseDir: "./testdata/fixture-sanitize-path",
620+
file: "env/unit/.terraform-version",
621+
want: "testdata/fixture-sanitize-path/env/unit/.terraform-version",
616622
},
617623
{
618624
name: "base dir is empty",
@@ -646,7 +652,7 @@ func Test_sanitizePath(t *testing.T) {
646652
name: "file is just a dot",
647653
baseDir: "./testdata/fixture-sanitize-path/env/unit",
648654
file: ".",
649-
want: "./testdata/fixture-sanitize-path/env/unit/.",
655+
want: "testdata/fixture-sanitize-path/env/unit",
650656
wantErr: false,
651657
},
652658
{

0 commit comments

Comments
 (0)