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
82 changes: 82 additions & 0 deletions actions/json.go
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@ import (
"fmt"
"io"
"os"
"path/filepath"

"github.com/AxeForging/pipekit/services"

Expand Down Expand Up @@ -82,6 +83,7 @@ func dataSetCmd(def services.DataFormat) cli.Command {
cli.StringFlag{Name: "json-value, j", Usage: "JSON-encoded value (object/array/number/bool)"},
cli.BoolFlag{Name: "in-place, i", Usage: "write back to the file (default: stdout)"},
cli.BoolFlag{Name: "pretty", Usage: "pretty-print output"},
cli.BoolFlag{Name: "preserve, P", Usage: "surgical edit: keep comments/formatting, change only the target (yaml, json)"},
},
Action: func(c *cli.Context) error {
file, err := firstArgOrErr(c, "FILE")
Expand All @@ -101,6 +103,12 @@ func dataSetCmd(def services.DataFormat) cli.Command {
newVal = c.String("value")
}

if c.Bool("preserve") {
return writePreserved(c, file, def, c.Bool("in-place"), func(data []byte, format services.DataFormat) ([]byte, error) {
return services.SetPreserving(data, format, path, newVal)
})
}

doc, _, err := loadFileWithDefault(file, def)
if err != nil {
return err
Expand All @@ -123,6 +131,7 @@ func dataDelCmd(def services.DataFormat) cli.Command {
cli.StringFlag{Name: "path, p", Usage: "path to delete"},
cli.BoolFlag{Name: "in-place, i"},
cli.BoolFlag{Name: "pretty"},
cli.BoolFlag{Name: "preserve, P", Usage: "surgical edit: keep comments/formatting, remove only the target (yaml, json)"},
},
Action: func(c *cli.Context) error {
file, err := firstArgOrErr(c, "FILE")
Expand All @@ -133,6 +142,13 @@ func dataDelCmd(def services.DataFormat) cli.Command {
if path == "" {
return cli.NewExitError("--path required", 1)
}

if c.Bool("preserve") {
return writePreserved(c, file, def, c.Bool("in-place"), func(data []byte, format services.DataFormat) ([]byte, error) {
return services.DelPreserving(data, format, path)
})
}

doc, _, err := loadFileWithDefault(file, def)
if err != nil {
return err
Expand Down Expand Up @@ -357,6 +373,72 @@ func writeResult(c *cli.Context, srcPath string, doc interface{}, def services.D
return nil
}

// writePreserved reads the source file's raw bytes, applies a formatting-
// preserving edit, and either writes back in place or prints to stdout. Unlike
// writeResult it never round-trips through Decode/Encode, so the file is changed
// only where the edit lands.
func writePreserved(c *cli.Context, srcPath string, def services.DataFormat, inPlace bool, edit func([]byte, services.DataFormat) ([]byte, error)) error {
data, err := os.ReadFile(srcPath)
if err != nil {
return err
}
format := services.DetectFormat(srcPath)
if format == "" {
format = def
}
out, err := edit(data, format)
if err != nil {
return err
}
if inPlace {
return atomicWriteFile(srcPath, out)
}
fmt.Print(string(out))
return nil
}

// atomicWriteFile writes data to a temp file in the same directory, fsyncs it,
// then renames it over the target. The rename is atomic on POSIX, so a crash or
// kill mid-write leaves the original file fully intact rather than truncated.
// The original file's permission bits are preserved.
func atomicWriteFile(path string, data []byte) error {
mode := os.FileMode(0644)
if info, err := os.Stat(path); err == nil {
mode = info.Mode().Perm()
}
dir := filepath.Dir(path)
tmp, err := os.CreateTemp(dir, ".pipekit-*.tmp")
if err != nil {
return err
}
tmpName := tmp.Name()
cleanup := func() { _ = os.Remove(tmpName) }

if _, err := tmp.Write(data); err != nil {
tmp.Close()
cleanup()
return err
}
if err := tmp.Sync(); err != nil {
tmp.Close()
cleanup()
return err
}
if err := tmp.Close(); err != nil {
cleanup()
return err
}
if err := os.Chmod(tmpName, mode); err != nil {
cleanup()
return err
}
if err := os.Rename(tmpName, path); err != nil {
cleanup()
return err
}
return nil
}

func pickFormat(flag, outputPath string, def services.DataFormat) services.DataFormat {
if flag != "" {
return services.FormatString(flag)
Expand Down
22 changes: 22 additions & 0 deletions docs/COMMANDS.md
Original file line number Diff line number Diff line change
Expand Up @@ -978,8 +978,30 @@ pipekit json get values.yaml --path '.image.tag' --raw --to-github-output IMAGE_
pipekit json set values.yaml --path '.image.tag' --value 'v2.0.0' --in-place
pipekit json set config.json --path '.flags' --json-value '["a","b"]' --pretty
pipekit json del values.yaml --path '.legacy' --in-place

# Surgical edit — change ONLY the target, keep comments/key-order/quoting/spacing
pipekit yaml set values.yaml --path '.image.tag' --value 'v2.0.0' --in-place --preserve
pipekit json set config.json --path '.newKey' --json-value '{"on":true}' --in-place --preserve # insert
pipekit json del config.json --path '.legacy' --in-place --preserve
```

By default `set`/`del` parse the document and re-serialize it, which normalizes
formatting (comments dropped, keys reordered, re-indented). Add `--preserve`
(`-P`) for a surgical, byte-level edit that touches **only** the targeted node
and leaves every other byte identical — comments (including their column
alignment), key order, quoting style, indentation, and blank lines are all kept.
Ideal for hand-maintained files like Helm `values.yaml`.

- Supported with `--preserve`: **yaml**, **json** (toml/csv return a clear error).
- Editing an existing value, deleting a key, and **inserting a new key into an
existing object** are all supported and formatting-matched to siblings.
- In-place writes are **atomic** (temp file + fsync + rename) and keep the
original file's permission bits, so a crash mid-write can't truncate the file.
- Safety: every YAML splice is re-parsed and validated; if the result wouldn't
hold the intended value (e.g. a type-ambiguous edit like setting a plain
numeric field to a numeric string) it automatically falls back to the safe
re-encode path rather than risk a wrong edit.

</details>

<details>
Expand Down
3 changes: 2 additions & 1 deletion docs/EXAMPLES.md
Original file line number Diff line number Diff line change
Expand Up @@ -426,7 +426,8 @@ kubectl apply -f /tmp/deployment.yaml

```sh
# Bump only the .image.tag in values.yaml without touching anything else
pipekit yaml set chart/values.yaml --path '.image.tag' --value 'v1.2.3' --in-place
# (--preserve keeps comments, key order, and quoting exactly as-is)
pipekit yaml set chart/values.yaml --path '.image.tag' --value 'v1.2.3' --in-place --preserve

# Or: deep-merge a per-env overlay
pipekit yaml merge chart/values.yaml chart/values.prod.yaml --output /tmp/merged.yaml
Expand Down
110 changes: 110 additions & 0 deletions integration/integration_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -135,6 +135,116 @@ func TestE2E_JSONGetSetMerge(t *testing.T) {
}
}

// TestE2E_YAMLSetPreserve verifies that `--preserve` performs a surgical in-place
// edit: only the targeted value changes, while comments, key order, and quoting
// of every other line are left byte-for-byte intact.
func TestE2E_YAMLSetPreserve(t *testing.T) {
dir := t.TempDir()
values := filepath.Join(dir, "values.yaml")
original := `# Helm values
image:
repository: myapp # do not touch
tag: "v1.0.0"
replicas: 3
`
if err := os.WriteFile(values, []byte(original), 0644); err != nil {
t.Fatal(err)
}

_, stderr, code := runPipekit(t,
[]string{"yaml", "set", values, "--path", ".image.tag", "--value", "v2.0.0", "--in-place", "--preserve"}, "")
if code != 0 {
t.Fatalf("preserve set exit %d, stderr: %s", code, stderr)
}
got, _ := os.ReadFile(values)
gotStr := string(got)

for _, want := range []string{"# Helm values", "repository: myapp # do not touch", `tag: "v2.0.0"`, "replicas: 3"} {
if !strings.Contains(gotStr, want) {
t.Errorf("preserve lost %q:\n%s", want, gotStr)
}
}
if strings.Contains(gotStr, "v1.0.0") {
t.Errorf("old value should be gone:\n%s", gotStr)
}

// Backward-compat sanity: the same edit WITHOUT --preserve still works,
// just normalizing formatting (comments dropped).
if err := os.WriteFile(values, []byte(original), 0644); err != nil {
t.Fatal(err)
}
_, _, code = runPipekit(t,
[]string{"yaml", "set", values, "--path", ".image.tag", "--value", "v2.0.0", "--in-place"}, "")
if code != 0 {
t.Fatalf("legacy set exit %d", code)
}
legacy, _ := os.ReadFile(values)
if !strings.Contains(string(legacy), "v2.0.0") {
t.Errorf("legacy set failed:\n%s", legacy)
}
}

// TestE2E_JSONDelPreserve verifies surgical key removal keeps surrounding JSON
// formatting intact.
func TestE2E_JSONDelPreserve(t *testing.T) {
dir := t.TempDir()
cfg := filepath.Join(dir, "config.json")
original := "{\n \"a\": 1,\n \"b\": 2,\n \"c\": 3\n}\n"
if err := os.WriteFile(cfg, []byte(original), 0644); err != nil {
t.Fatal(err)
}
_, stderr, code := runPipekit(t,
[]string{"json", "del", cfg, "--path", ".b", "--in-place", "--preserve"}, "")
if code != 0 {
t.Fatalf("preserve del exit %d, stderr: %s", code, stderr)
}
want := "{\n \"a\": 1,\n \"c\": 3\n}\n"
if got, _ := os.ReadFile(cfg); string(got) != want {
t.Errorf("got:\n%s\nwant:\n%s", got, want)
}
}

// TestE2E_JSONSetPreserveInsert verifies `set --preserve` can add a new key to
// an existing object, formatting-matched to its siblings.
func TestE2E_JSONSetPreserveInsert(t *testing.T) {
dir := t.TempDir()
cfg := filepath.Join(dir, "config.json")
if err := os.WriteFile(cfg, []byte("{\n \"a\": 1\n}\n"), 0644); err != nil {
t.Fatal(err)
}
_, stderr, code := runPipekit(t,
[]string{"json", "set", cfg, "--path", ".b", "--json-value", "2", "--in-place", "--preserve"}, "")
if code != 0 {
t.Fatalf("insert exit %d, stderr: %s", code, stderr)
}
want := "{\n \"a\": 1,\n \"b\": 2\n}\n"
if got, _ := os.ReadFile(cfg); string(got) != want {
t.Errorf("got:\n%s\nwant:\n%s", got, want)
}
}

// TestE2E_PreservePreservesFileMode verifies the atomic in-place write keeps the
// original file's permission bits.
func TestE2E_PreservePreservesFileMode(t *testing.T) {
dir := t.TempDir()
f := filepath.Join(dir, "values.yaml")
if err := os.WriteFile(f, []byte("tag: v1\n"), 0640); err != nil {
t.Fatal(err)
}
_, _, code := runPipekit(t,
[]string{"yaml", "set", f, "--path", ".tag", "--value", "v2", "--in-place", "--preserve"}, "")
if code != 0 {
t.Fatalf("exit %d", code)
}
info, err := os.Stat(f)
if err != nil {
t.Fatal(err)
}
if info.Mode().Perm() != 0640 {
t.Errorf("mode changed: got %o want 640", info.Mode().Perm())
}
}

func TestE2E_RenderFile(t *testing.T) {
dir := t.TempDir()
tmpl := filepath.Join(dir, "v.tpl")
Expand Down
Loading
Loading