[object Object]

← back to Cli Printing Press

fix(cli): preserve novel cli files on force generate (#747)

9675f8b2b39be4639a7bdd2e34cc8fcd7e6b00f7 · 2026-05-08 14:38:41 -0700 · Trevin Chow

Files touched

Diff

commit 9675f8b2b39be4639a7bdd2e34cc8fcd7e6b00f7
Author: Trevin Chow <trevin@trevinchow.com>
Date:   Fri May 8 14:38:41 2026 -0700

    fix(cli): preserve novel cli files on force generate (#747)
---
 internal/cli/generate_test.go            | 80 ++++++++++++++++++++++++++++++++
 internal/cli/root.go                     | 65 +++++++++++++++++++++++++-
 internal/generatedmarker/marker.go       | 28 +++++++++++
 internal/generatedmarker/marker_test.go  | 35 ++++++++++++++
 internal/pipeline/regenmerge/classify.go | 26 ++---------
 5 files changed, 210 insertions(+), 24 deletions(-)

diff --git a/internal/cli/generate_test.go b/internal/cli/generate_test.go
index f4067740..c4623929 100644
--- a/internal/cli/generate_test.go
+++ b/internal/cli/generate_test.go
@@ -5,6 +5,7 @@ import (
 	"go/parser"
 	"go/token"
 	"os"
+	"os/exec"
 	"path/filepath"
 	"testing"
 
@@ -15,6 +16,77 @@ import (
 	"github.com/stretchr/testify/require"
 )
 
+func TestGenerateCmdForcePreservesHandAuthoredInternalCLI(t *testing.T) {
+	t.Parallel()
+
+	dir := t.TempDir()
+	specPath := filepath.Join(dir, "spec.yaml")
+	outputDir := filepath.Join(dir, "regenapp")
+	require.NoError(t, os.WriteFile(specPath, []byte(`name: regenapp
+description: Regen app API
+version: 0.1.0
+base_url: https://api.example.com
+auth:
+  type: none
+config:
+  format: toml
+  path: ~/.config/regenapp-pp-cli/config.toml
+resources:
+  items:
+    description: Manage items
+    endpoints:
+      list:
+        method: GET
+        path: /items
+        description: List items
+`), 0o644))
+
+	runGenerate := func() {
+		cmd := newGenerateCmd()
+		cmd.SetArgs([]string{
+			"--spec", specPath,
+			"--output", outputDir,
+			"--validate=false",
+			"--force",
+		})
+		require.NoError(t, cmd.Execute())
+	}
+
+	runGenerate()
+
+	novelPath := filepath.Join(outputDir, "internal", "cli", "novel_keep.go")
+	novelSource := []byte(`package cli
+
+func novelPreservedSentinel() string { return "preserved" }
+`)
+	require.NoError(t, os.WriteFile(novelPath, novelSource, 0o644))
+
+	rootPath := filepath.Join(outputDir, "internal", "cli", "root.go")
+	require.NoError(t, os.WriteFile(rootPath, []byte("package cli\n\nfunc brokenGeneratedEdit() {\n"), 0o644))
+	staleGeneratedPath := filepath.Join(outputDir, "internal", "cli", "old_generated.go")
+	require.NoError(t, os.WriteFile(staleGeneratedPath, []byte(`// Copyright 2026 trevin-chow.
+// Generated by CLI Printing Press (https://github.com/mvanhorn/cli-printing-press). DO NOT EDIT.
+package cli
+
+func staleGeneratedCommand() {}
+`), 0o644))
+
+	runGenerate()
+
+	gotNovel, err := os.ReadFile(novelPath)
+	require.NoError(t, err)
+	assert.Equal(t, string(novelSource), string(gotNovel))
+
+	rootGo, err := os.ReadFile(rootPath)
+	require.NoError(t, err)
+	assert.Contains(t, string(rootGo), "Generated by CLI Printing Press")
+	assert.NotContains(t, string(rootGo), "brokenGeneratedEdit")
+	assert.NoFileExists(t, staleGeneratedPath)
+
+	runGoCommandForCLITest(t, outputDir, "mod", "tidy")
+	runGoCommandForCLITest(t, outputDir, "build", "./cmd/regenapp-pp-cli")
+}
+
 func TestGenerateCmdConsumesTrafficAnalysis(t *testing.T) {
 	t.Parallel()
 
@@ -856,3 +928,11 @@ func TestEnrichSpecFromCatalogKeepsExplicitDisplayName(t *testing.T) {
 
 	assert.Equal(t, "Spec.dev", apiSpec.DisplayName)
 }
+
+func runGoCommandForCLITest(t *testing.T, dir string, args ...string) {
+	t.Helper()
+	cmd := exec.Command("go", args...)
+	cmd.Dir = dir
+	out, err := cmd.CombinedOutput()
+	require.NoError(t, err, string(out))
+}
diff --git a/internal/cli/root.go b/internal/cli/root.go
index 7eff875d..e7c029e7 100644
--- a/internal/cli/root.go
+++ b/internal/cli/root.go
@@ -19,6 +19,7 @@ import (
 	"github.com/mvanhorn/cli-printing-press/v4/internal/browsersniff"
 	"github.com/mvanhorn/cli-printing-press/v4/internal/catalog"
 	"github.com/mvanhorn/cli-printing-press/v4/internal/docspec"
+	"github.com/mvanhorn/cli-printing-press/v4/internal/generatedmarker"
 	"github.com/mvanhorn/cli-printing-press/v4/internal/generator"
 	"github.com/mvanhorn/cli-printing-press/v4/internal/graphql"
 	"github.com/mvanhorn/cli-printing-press/v4/internal/llm"
@@ -765,17 +766,24 @@ func httpTransportPriority(value string) int {
 
 // claimOrForce resolves the output directory based on --force and --output flags.
 //
-//   - force=true:  RemoveAll the target, then create it fresh (claims exact slot)
+//   - force=true:  RemoveAll the target, then create it fresh (claims exact slot), preserving hand-authored internal/cli/*.go files
 //   - explicit output (--output set) without force: error if exists and non-empty
 //   - default (no --output, no --force): auto-increment via ClaimOutputDir
 func claimOrForce(absOut string, force bool, explicitOutput bool) (string, error) {
 	if force {
+		preserved, err := preserveHandAuthoredInternalCLIFiles(absOut)
+		if err != nil {
+			return "", err
+		}
 		if err := os.RemoveAll(absOut); err != nil {
 			return "", fmt.Errorf("removing existing output dir: %w", err)
 		}
 		if err := os.MkdirAll(absOut, 0o755); err != nil {
 			return "", fmt.Errorf("creating output dir: %w", err)
 		}
+		if err := restorePreservedFiles(absOut, preserved); err != nil {
+			return "", err
+		}
 		return absOut, nil
 	}
 
@@ -795,6 +803,61 @@ func claimOrForce(absOut string, force bool, explicitOutput bool) (string, error
 	return pipeline.ClaimOutputDir(absOut)
 }
 
+type preservedFile struct {
+	relPath string
+	data    []byte
+	mode    os.FileMode
+}
+
+func preserveHandAuthoredInternalCLIFiles(absOut string) ([]preservedFile, error) {
+	cliDir := filepath.Join(absOut, "internal", "cli")
+	entries, err := os.ReadDir(cliDir)
+	if err != nil {
+		if os.IsNotExist(err) {
+			return nil, nil
+		}
+		return nil, fmt.Errorf("reading internal/cli for hand-authored files: %w", err)
+	}
+
+	var preserved []preservedFile
+	for _, entry := range entries {
+		if entry.IsDir() || filepath.Ext(entry.Name()) != ".go" {
+			continue
+		}
+		path := filepath.Join(cliDir, entry.Name())
+		if generatedmarker.HasInFile(path) {
+			continue
+		}
+		data, err := os.ReadFile(path)
+		if err != nil {
+			return nil, fmt.Errorf("reading hand-authored candidate %s: %w", path, err)
+		}
+		info, err := entry.Info()
+		if err != nil {
+			return nil, fmt.Errorf("statting hand-authored candidate %s: %w", path, err)
+		}
+		preserved = append(preserved, preservedFile{
+			relPath: filepath.Join("internal", "cli", entry.Name()),
+			data:    data,
+			mode:    info.Mode().Perm(),
+		})
+	}
+	return preserved, nil
+}
+
+func restorePreservedFiles(absOut string, files []preservedFile) error {
+	for _, file := range files {
+		path := filepath.Join(absOut, file.relPath)
+		if err := os.MkdirAll(filepath.Dir(path), 0o755); err != nil {
+			return fmt.Errorf("creating preserved file parent: %w", err)
+		}
+		if err := os.WriteFile(path, file.data, file.mode); err != nil {
+			return fmt.Errorf("restoring preserved file %s: %w", path, err)
+		}
+	}
+	return nil
+}
+
 func fetchOrCacheSpec(specURL string, refresh bool, skipCache bool) ([]byte, error) {
 	sum := sha256.Sum256([]byte(specURL))
 	cacheKey := hex.EncodeToString(sum[:])
diff --git a/internal/generatedmarker/marker.go b/internal/generatedmarker/marker.go
new file mode 100644
index 00000000..f93f0124
--- /dev/null
+++ b/internal/generatedmarker/marker.go
@@ -0,0 +1,28 @@
+package generatedmarker
+
+import (
+	"bufio"
+	"os"
+	"strings"
+)
+
+// Text is the marker emitted near the head of generated source files.
+const Text = "Generated by CLI Printing Press"
+
+// HasInFile reports whether one of the file's first few lines contains Text.
+func HasInFile(path string) bool {
+	f, err := os.Open(path)
+	if err != nil {
+		return false
+	}
+	defer func() { _ = f.Close() }()
+
+	scanner := bufio.NewScanner(f)
+	scanner.Buffer(make([]byte, 4096), 64*1024)
+	for line := 0; line < 5 && scanner.Scan(); line++ {
+		if strings.Contains(scanner.Text(), Text) {
+			return true
+		}
+	}
+	return false
+}
diff --git a/internal/generatedmarker/marker_test.go b/internal/generatedmarker/marker_test.go
new file mode 100644
index 00000000..7de5ebd0
--- /dev/null
+++ b/internal/generatedmarker/marker_test.go
@@ -0,0 +1,35 @@
+package generatedmarker
+
+import (
+	"os"
+	"path/filepath"
+	"testing"
+
+	"github.com/stretchr/testify/assert"
+	"github.com/stretchr/testify/require"
+)
+
+func TestHasInFileScansGeneratedMarkerNearHead(t *testing.T) {
+	t.Parallel()
+
+	dir := t.TempDir()
+	path := filepath.Join(dir, "generated.go")
+	require.NoError(t, os.WriteFile(path, []byte(`// Copyright 2026 trevin-chow.
+// SPDX-License-Identifier: Apache-2.0
+// Extra generated preamble.
+// Generated by CLI Printing Press (https://github.com/mvanhorn/cli-printing-press). DO NOT EDIT.
+package cli
+`), 0o644))
+
+	assert.True(t, HasInFile(path))
+}
+
+func TestHasInFileIgnoresHandAuthoredFile(t *testing.T) {
+	t.Parallel()
+
+	dir := t.TempDir()
+	path := filepath.Join(dir, "novel.go")
+	require.NoError(t, os.WriteFile(path, []byte("package cli\n\nfunc novel() {}\n"), 0o644))
+
+	assert.False(t, HasInFile(path))
+}
diff --git a/internal/pipeline/regenmerge/classify.go b/internal/pipeline/regenmerge/classify.go
index 3fecc105..de62b2ca 100644
--- a/internal/pipeline/regenmerge/classify.go
+++ b/internal/pipeline/regenmerge/classify.go
@@ -1,8 +1,6 @@
 package regenmerge
 
 import (
-	"bufio"
-	"bytes"
 	"fmt"
 	"go/ast"
 	"go/parser"
@@ -12,11 +10,9 @@ import (
 	"path/filepath"
 	"sort"
 	"strings"
-)
 
-// templatedMarker is the line-2 comment every newer template emits. Matched
-// case-sensitively as a substring of the second line.
-const templatedMarker = "Generated by CLI Printing Press"
+	"github.com/mvanhorn/cli-printing-press/v4/internal/generatedmarker"
+)
 
 // declSet maps a canonical decl name to whether it was found. Names follow
 // the convention used by extractDecls below.
@@ -120,24 +116,8 @@ func canonicalFuncName(fn *ast.FuncDecl) string {
 	return name
 }
 
-// hasTemplatedMarker reports whether one of the file's first few lines
-// contains the "Generated by CLI Printing Press" marker. Scans the head of
-// the file rather than a fixed byte slab so a long line-1 SPDX/license
-// header can't push the marker past the read window.
 func hasTemplatedMarker(filename string) bool {
-	f, err := os.Open(filename)
-	if err != nil {
-		return false
-	}
-	defer func() { _ = f.Close() }()
-	scanner := bufio.NewScanner(f)
-	scanner.Buffer(make([]byte, 4096), 64*1024)
-	for line := 0; line < 5 && scanner.Scan(); line++ {
-		if bytes.Contains(scanner.Bytes(), []byte(templatedMarker)) {
-			return true
-		}
-	}
-	return false
+	return generatedmarker.HasInFile(filename)
 }
 
 // classifyFiles walks both trees, building the combined file-path set and

← 64363921 fix(generator): use simple backticks in auth_client_credenti  ·  back to Cli Printing Press  ·  fix(cli): align generated install and rename metadata (#749) ca580efc →