Skip to content

Redesign checksum config and add migration tool - #342

Merged
kindermax merged 7 commits into
masterfrom
codex/redesign-checksums-and-migration
Jul 29, 2026
Merged

Redesign checksum config and add migration tool#342
kindermax merged 7 commits into
masterfrom
codex/redesign-checksums-and-migration

Conversation

@kindermax

@kindermax kindermax commented Apr 26, 2026

Copy link
Copy Markdown
Collaborator

Summary

  • Add the new checksum config shape with checksum.files, checksum.sh, and checksum.persist while keeping legacy checksum syntax working
  • Add lets self fix to migrate local configs and mixins, with dry-run preview output
  • Preserve backward compatibility in runtime behavior and add deprecation warnings for persist_checksum
  • Update docs, schema, and tests for the new checksum flow

Testing

  • go test ./...
  • lets lint
  • lets test-bats command_checksum.bats
  • lets test-bats command_persist_checksum.bats
  • lets test-bats self_fix.bats

Summary by Sourcery

Redesign command checksum configuration to support file- and script-based checksums with a new persisted checksum schema, and introduce a self-fix migration command to upgrade existing configs while preserving backward compatibility.

New Features:

  • Add new command checksum schema with checksum.files, checksum.sh, and checksum.persist support.
  • Add lets self fix CLI command with optional --dry-run to automatically migrate local configs and mixins from deprecated checksum syntax.
  • Allow command checksums to be computed from shell scripts using the configured shell and environment.

Enhancements:

  • Track use of deprecated persist_checksum and emit colored warnings at runtime.
  • Extend checksum calculation and executor initialization to respect command-specific shell, workdir, and environment when computing checksums.
  • Improve logging formatter to colorize warnings and adjust tests to cover warn-level formatting.
  • Add config migration utilities for YAML-based transformations, including checksum migration and formatting of command spacing.

Documentation:

  • Document the new checksum configuration (checksum.files, checksum.sh, checksum.persist) and deprecate the legacy checksum list/map and persist_checksum syntax.
  • Update advanced usage docs and changelog with the new checksum flow and self-fix migration command.
  • Describe lets self fix CLI usage and its --dry-run preview mode.

Tests:

  • Add unit and bats tests covering new checksum parsing, script-based checksum calculation, persisted checksum behavior with new syntax, and the self-fix migration command.

Chores:

  • Update project configs to use the new checksum syntax in internal lets.yaml and test fixtures.

@sourcery-ai

sourcery-ai Bot commented Apr 26, 2026

Copy link
Copy Markdown
Contributor

Reviewer's Guide

Redesigns command checksum configuration to support a new checksum.files / checksum.sh / checksum.persist schema, wires it into execution with script-based checksum calculation and warnings for deprecated persist_checksum, and introduces a lets self fix migration command (with dry-run) plus tests/docs/schema updates.

Sequence diagram for the new lets self fix migration command

sequenceDiagram
  actor User
  participant CobraSelf as self_cmd.fix
  participant Migrate as migrate
  participant ChecksumMigration

  User->>CobraSelf: lets self fix [--dry-run]
  CobraSelf->>CobraSelf: parse flags (--dry-run, --config)
  CobraSelf->>Migrate: Fix(configName, LETS_CONFIG_DIR, dryRun, out)
  Migrate->>Migrate: FindConfig(configName, configDir)
  Migrate->>Migrate: collectConfigPaths(rootPath, workDir)
  loop each config path
    Migrate->>ChecksumMigration: Apply(root *yaml.Node)
    alt checksum or persist_checksum present
      ChecksumMigration->>ChecksumMigration: migrateCommandChecksum(command)
      ChecksumMigration-->>Migrate: changed = true
    else no migration needed
      ChecksumMigration-->>Migrate: changed = false
    end
  end
  alt dry-run
    Migrate-->>CobraSelf: Result with Previews
  else write files
    Migrate-->>CobraSelf: Result with ChangedFiles, Applied
  end
  CobraSelf-->>User: migrated config output / status messages
Loading

File-Level Changes

Change Details Files
Introduce new command checksum configuration schema with files/shell/persist options while keeping legacy syntax compatible.
  • Extend Command struct with checksum shell script, deprecation flag for persist_checksum, and pointer-based persist field in YAML parsing.
  • Add CommandChecksum and ChecksumFiles types with custom YAML unmarshalling to support checksum.files, checksum.sh, and checksum.persist and validate mutually exclusive options.
  • Update command checksum parsing tests to cover legacy list/map syntax, new files-based syntax, and shell-based checksum scripts.
internal/config/config/command.go
internal/config/config/checksum.go
internal/config/config/command_test.go
Execute checksums from either file lists or shell scripts and plumb command shell/env into checksum calculation.
  • Add CalculateChecksumFromConfig and CalculateChecksumFromScript helpers to execute checksum scripts in a given shell and working directory with merged env.
  • Change Command.ChecksumCalculator to delegate to config-aware checksum calculation supporting files or sh.
  • Update Executor.initCmd to compute checksum shell/workDir/env, warn on deprecated persist_checksum usage, and pass these into checksum calculation.
internal/checksum/checksum.go
internal/checksum/checksum_test.go
internal/config/config/command.go
internal/executor/executor.go
Emit colored warn-level logs and extend logging API with a Warn helper.
  • Make formatter colorize warn-level prefixes and messages when color is enabled, leaving disabled-color paths unchanged.
  • Add ExecLogger.Warn method mirroring Info/Debug with warn level routing.
  • Add tests to verify warn-level coloring and adjust expectations for non-colorized output when color is disabled.
internal/logging/formatter.go
internal/logging/log.go
internal/logging/log_test.go
Add lets self fix migration command and underlying migration framework to rewrite deprecated checksum config into the new schema.
  • Introduce generic migrate package with Migration interface, Fix orchestration, YAML decode/encode helpers, command-spacing formatter, and mixin path collection including remote mixin reporting.
  • Implement checksum-specific migration that converts legacy checksum plus command-level persist_checksum into checksum.files/checksum.persist, and removes persist_checksum, preserving existing new-format checksum objects.
  • Wire new fix subcommand under lets self with optional --dry-run that prints migrated configs instead of writing, and returns migration results to stdout.
  • Add bats tests and YAML fixtures to cover dry-run output, on-disk rewrites of root configs and local mixins, and remote mixin warnings.
internal/config/migrate/migrate.go
internal/config/migrate/checksum.go
internal/config/migrate/checksum_test.go
internal/cmd/fix.go
tests/self_fix.bats
tests/self_fix/lets.old.yaml
tests/self_fix/mixin.old.yaml
Update configuration/docs/tests to use new checksum schema and cover new behavior.
  • Rewrite config and advanced usage docs to describe checksum.files, checksum.sh, and checksum.persist, deprecate direct list/map checksum plus command-level persist_checksum, and add examples including script-based checksums.
  • Document new lets self fix CLI command and record checksum redesign and migration command in the changelog.
  • Adjust existing bats tests and test configs to assert deprecation warnings for legacy persist_checksum, add coverage for new checksum syntax (files and sh), and use new syntax in project lets.yaml and test fixtures.
  • Ensure schema.json and related documentation reflect the new checksum flow (where applicable).
docs/docs/config.md
docs/docs/advanced_usage.md
docs/docs/cli.md
docs/docs/changelog.md
tests/command_checksum.bats
tests/command_checksum/lets.yaml
tests/command_persist_checksum.bats
tests/command_persist_checksum/lets.yaml
lets.yaml
docs/static/schema.json

Possibly linked issues

  • #Redesign checksum: PR introduces checksum.files/sh/persist, enforces new validation rules, updates docs, and adds migration tooling for deprecated syntax.
  • #unknown: PR implements the requested lets self fix migration command with --dry-run and deprecation warnings/docs as described.

Tips and commands

Interacting with Sourcery

  • Trigger a new review: Comment @sourcery-ai review on the pull request.
  • Continue discussions: Reply directly to Sourcery's review comments.
  • Generate a GitHub issue from a review comment: Ask Sourcery to create an
    issue from a review comment by replying to it. You can also reply to a
    review comment with @sourcery-ai issue to create an issue from it.
  • Generate a pull request title: Write @sourcery-ai anywhere in the pull
    request title to generate a title at any time. You can also comment
    @sourcery-ai title on the pull request to (re-)generate the title at any time.
  • Generate a pull request summary: Write @sourcery-ai summary anywhere in
    the pull request body to generate a PR summary at any time exactly where you
    want it. You can also comment @sourcery-ai summary on the pull request to
    (re-)generate the summary at any time.
  • Generate reviewer's guide: Comment @sourcery-ai guide on the pull
    request to (re-)generate the reviewer's guide at any time.
  • Resolve all Sourcery comments: Comment @sourcery-ai resolve on the
    pull request to resolve all Sourcery comments. Useful if you've already
    addressed all the comments and don't want to see them anymore.
  • Dismiss all Sourcery reviews: Comment @sourcery-ai dismiss on the pull
    request to dismiss all existing Sourcery reviews. Especially useful if you
    want to start fresh with a new review - don't forget to comment
    @sourcery-ai review to trigger a new review!

Customizing Your Experience

Access your dashboard to:

  • Enable or disable review features such as the Sourcery-generated pull request
    summary, the reviewer's guide, and others.
  • Change the review language.
  • Add, remove or edit custom review instructions.
  • Adjust other review settings.

Getting Help

@kindermax
kindermax force-pushed the codex/redesign-checksums-and-migration branch from 41a40d1 to 1d376fd Compare July 29, 2026 06:58
@kindermax
kindermax marked this pull request as ready for review July 29, 2026 07:17

@sourcery-ai sourcery-ai Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Hey - I've found 1 security issue, 5 other issues, and left some high level feedback:

Security issues:

  • Detected non-static command inside Command. Audit the input to 'exec.Command'. If unverified user data can reach this call site, this is a code injection vulnerability. A malicious actor can inject a malicious script to execute arbitrary code. (link)

General comments:

  • In Executor.initCmd, you compute checksumWorkDir based on command-specific overrides but still call cmd.ChecksumCalculator with e.cfg.WorkDir; this should pass checksumWorkDir so checksum scripts and file scans respect per-command work_dir.
  • The migration code in fixFile writes updated configs with a hard-coded 0o644 mode; consider preserving the original file permissions (via os.Stat/FileMode) to avoid unintentionally changing executable or read-only bits.
Prompt for AI Agents
Please address the comments from this code review:

## Overall Comments
- In `Executor.initCmd`, you compute `checksumWorkDir` based on command-specific overrides but still call `cmd.ChecksumCalculator` with `e.cfg.WorkDir`; this should pass `checksumWorkDir` so checksum scripts and file scans respect per-command `work_dir`.
- The migration code in `fixFile` writes updated configs with a hard-coded `0o644` mode; consider preserving the original file permissions (via `os.Stat`/`FileMode`) to avoid unintentionally changing executable or read-only bits.

## Individual Comments

### Comment 1
<location path="internal/executor/executor.go" line_range="193-202" />
<code_context>
+		checksumShell = cmd.Shell
+	}
+
+	checksumWorkDir := e.cfg.WorkDir
+	if cmd.WorkDir != "" {
+		checksumWorkDir = cmd.WorkDir
+	}
+
+	checksumEnv := e.cfg.CommandBuiltinEnv(cmd, checksumShell, checksumWorkDir)
+	maps.Copy(checksumEnv, e.cfg.GetEnv())
+
 	// calculate checksum if needed
-	if err := cmd.ChecksumCalculator(e.cfg.WorkDir); err != nil {
+	if err := cmd.ChecksumCalculator(e.cfg.WorkDir, checksumShell, checksumEnv); err != nil {
 		return fmt.Errorf("failed to calculate checksum for command '%s': %w", cmd.Name, err)
 	}
</code_context>
<issue_to_address>
**issue (bug_risk):** Checksum calculation ignores overridden command work_dir

`checksumWorkDir` correctly reflects the command’s effective working directory, but `ChecksumCalculator` still receives `e.cfg.WorkDir`. This means checksums won’t respect per-command `work_dir` overrides and can be wrong or fail. Pass `checksumWorkDir` (and the corresponding env) into `ChecksumCalculator` instead.
</issue_to_address>

### Comment 2
<location path="internal/config/migrate/migrate.go" line_range="115-116" />
<code_context>
+
+	preview := ""
+
+	if !dryRun {
+		if err := os.WriteFile(path, updated, 0o644); err != nil {
+			return false, nil, "", fmt.Errorf("can not write config %s: %w", path, err)
+		}
</code_context>
<issue_to_address>
**suggestion (bug_risk):** Migration rewrites config files with fixed permissions, potentially changing original modes

`os.WriteFile(path, updated, 0o644)` always forces 0644, which may differ from the existing file’s permissions and unintentionally relax or change them. Please capture the current mode with `os.Stat` and pass that `FileMode` into `os.WriteFile` so the migration preserves existing permissions.

Suggested implementation:

```golang
	if !dryRun {
		mode := fs.FileMode(0o644)

		if info, err := os.Stat(path); err == nil {
			mode = info.Mode()
		} else if !os.IsNotExist(err) {
			return false, nil, "", fmt.Errorf("can not stat config %s: %w", path, err)
		}

		if err := os.WriteFile(path, updated, mode); err != nil {
			return false, nil, "", fmt.Errorf("can not write config %s: %w", path, err)
		}

```

To compile successfully, ensure the file imports the `io/fs` package:

- In the import block of `internal/config/migrate/migrate.go`, add:
  `import "io/fs"` (or add `fs` to an existing grouped import).

If `fs` is already imported elsewhere in this file, no further changes are needed.
</issue_to_address>

### Comment 3
<location path="internal/config/config/command_test.go" line_range="49" />
<code_context>
 	})
 }
+
+func TestParseCommandChecksum(t *testing.T) {
+	t.Run("old list syntax", func(t *testing.T) {
+		text := dedent.Dedent(`
</code_context>
<issue_to_address>
**suggestion (testing):** Add tests for invalid checksum configurations (conflicting or incomplete settings) to exercise new validation branches

`UnmarshalYAML` now has several validation paths that `TestParseCommandChecksum` doesn’t cover: (1) both `checksum.files` and `checksum.sh` set; (2) `persist_checksum` set without `checksum`; (3) `persist_checksum` / `checksum.persist` set without any `checksum.files` or `checksum.sh`; and (4) conflicting `persist_checksum` vs `checksum.persist`. Please add table-driven tests asserting `yaml.Unmarshal` fails with the expected error for each case to cover these branches and prevent regressions.

Suggested implementation:

```golang
	"testing"

	"github.com/lets-cli/lets/internal/checksum"
	"github.com/lithammer/dedent"
	"gopkg.in/yaml.v3"
	"strings"
)

```

```golang
func TestParseCommandChecksum(t *testing.T) {
	t.Run("old list syntax", func(t *testing.T) {
		text := dedent.Dedent(`
		checksum:
		  - foo.txt
		persist_checksum: true
		cmd: echo ok
		`)
		command := CommandFixture(t, text)

		if !command.PersistChecksum {
			t.Fatal("expected persisted checksum")
		}
	})

	t.Run("invalid configurations", func(t *testing.T) {
		tests := []struct {
			name             string
			yamlText         string
			wantErrSubstring string
		}{
			{
				name: "both checksum.files and checksum.sh set",
				yamlText: dedent.Dedent(`
				cmd: echo ok
				checksum:
				  files:
				    - foo.txt
				  sh: echo "checksum"
				`),
				// Adjust this substring to whatever the UnmarshalYAML implementation returns
				wantErrSubstring: "checksum.files and checksum.sh",
			},
			{
				name: "persist_checksum set without checksum",
				yamlText: dedent.Dedent(`
				cmd: echo ok
				persist_checksum: true
				`),
				// Adjust this substring to match actual error wording
				wantErrSubstring: "persist_checksum requires checksum",
			},
			{
				name: "checksum.persist set without files or sh",
				yamlText: dedent.Dedent(`
				cmd: echo ok
				checksum:
				  persist: true
				`),
				// Adjust this substring to match actual error wording
				wantErrSubstring: "checksum.persist requires",
			},
			{
				name: "conflicting persist_checksum and checksum.persist",
				yamlText: dedent.Dedent(`
				cmd: echo ok
				persist_checksum: true
				checksum:
				  files:
				    - foo.txt
				  persist: false
				`),
				// Adjust this substring to match actual error wording
				wantErrSubstring: "conflict" ,
			},
		}

		for _, tt := range tests {
			t.Run(tt.name, func(t *testing.T) {
				var cmd Command

				err := yaml.Unmarshal([]byte(tt.yamlText), &cmd)
				if err == nil {
					t.Fatalf("expected error for invalid checksum configuration, got nil")
				}

				if tt.wantErrSubstring != "" && !strings.Contains(err.Error(), tt.wantErrSubstring) {
					t.Fatalf("expected error to contain %q, got %q", tt.wantErrSubstring, err.Error())
				}
			})
		}
	})
}

```

1. Ensure the type name `Command` in the new test matches the actual type that implements `UnmarshalYAML` for commands. If your type is named differently (e.g. `command`, `CommandConfig`), update the declaration `var cmd Command` accordingly.
2. The `wantErrSubstring` values are placeholders based on typical error messages; align each substring with the exact error strings returned by your `UnmarshalYAML` implementation so the assertions are stable.
3. If `UnmarshalYAML` is implemented on a wrapper struct (e.g. a higher-level config object containing commands), you may need to unmarshal into that type instead and adjust the YAML snippets to match the surrounding structure.
</issue_to_address>

### Comment 4
<location path="docs/docs/config.md" line_range="894" />
<code_context>
 This feature is useful when you want to know that something has changed between two executions of a command.

-`persist_checksum` can be used only if `checksum` declared for command.
+`checksum.persist` can be used only if `checksum.files` or `checksum.sh` declared for command.

 If set to `true`, each run all calculated checksums will be stored to disk.
</code_context>
<issue_to_address>
**suggestion (typo):** Grammar tweak: add auxiliary verb "is" before "declared".

You could say: "`checksum.persist` can be used only if `checksum.files` or `checksum.sh` is declared for the command" (or "are declared" if you treat them jointly) to improve grammatical clarity.

```suggestion
`checksum.persist` can be used only if `checksum.files` or `checksum.sh` is declared for the command.
```
</issue_to_address>

### Comment 5
<location path="docs/docs/advanced_usage.md" line_range="100" />
<code_context>
 We then can store this checksum somewhere in the file and check that stored checksum with a checksum from env.

-Fortunately, `lets` have an option for that - `persist_checksum`.
+Fortunately, `lets` have an option for that - `checksum.persist`.

-If `persist_cheksum` used with `checksum` `lets` will store new checksum to `.lets` dir and each time you run a command `lets` will check if stored checksum changed from the one from env.
</code_context>
<issue_to_address>
**issue (typo):** Subject–verb agreement: "lets" should "have" vs "has".

Rewrite this sentence as: “Fortunately, `lets` has an option for that - `checksum.persist`.”

```suggestion
Fortunately, `lets` has an option for that - `checksum.persist`.
```
</issue_to_address>

### Comment 6
<location path="internal/checksum/checksum.go" line_range="172" />
<code_context>
	cmd := exec.Command(shell, "-c", script)
</code_context>
<issue_to_address>
**security (go.lang.security.audit.dangerous-exec-command):** Detected non-static command inside Command. Audit the input to 'exec.Command'. If unverified user data can reach this call site, this is a code injection vulnerability. A malicious actor can inject a malicious script to execute arbitrary code.

*Source: opengrep*
</issue_to_address>

Sourcery is free for open source - if you like our reviews please consider sharing them ✨
Help me be more useful! Please click 👍 or 👎 on each comment and I'll use the feedback to improve your reviews.

Comment thread internal/executor/executor.go Outdated
Comment thread internal/config/migrate/migrate.go Outdated
})
}

func TestParseCommandChecksum(t *testing.T) {

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

suggestion (testing): Add tests for invalid checksum configurations (conflicting or incomplete settings) to exercise new validation branches

UnmarshalYAML now has several validation paths that TestParseCommandChecksum doesn’t cover: (1) both checksum.files and checksum.sh set; (2) persist_checksum set without checksum; (3) persist_checksum / checksum.persist set without any checksum.files or checksum.sh; and (4) conflicting persist_checksum vs checksum.persist. Please add table-driven tests asserting yaml.Unmarshal fails with the expected error for each case to cover these branches and prevent regressions.

Suggested implementation:

	"testing"

	"github.com/lets-cli/lets/internal/checksum"
	"github.com/lithammer/dedent"
	"gopkg.in/yaml.v3"
	"strings"
)
func TestParseCommandChecksum(t *testing.T) {
	t.Run("old list syntax", func(t *testing.T) {
		text := dedent.Dedent(`
		checksum:
		  - foo.txt
		persist_checksum: true
		cmd: echo ok
		`)
		command := CommandFixture(t, text)

		if !command.PersistChecksum {
			t.Fatal("expected persisted checksum")
		}
	})

	t.Run("invalid configurations", func(t *testing.T) {
		tests := []struct {
			name             string
			yamlText         string
			wantErrSubstring string
		}{
			{
				name: "both checksum.files and checksum.sh set",
				yamlText: dedent.Dedent(`
				cmd: echo ok
				checksum:
				  files:
				    - foo.txt
				  sh: echo "checksum"
				`),
				// Adjust this substring to whatever the UnmarshalYAML implementation returns
				wantErrSubstring: "checksum.files and checksum.sh",
			},
			{
				name: "persist_checksum set without checksum",
				yamlText: dedent.Dedent(`
				cmd: echo ok
				persist_checksum: true
				`),
				// Adjust this substring to match actual error wording
				wantErrSubstring: "persist_checksum requires checksum",
			},
			{
				name: "checksum.persist set without files or sh",
				yamlText: dedent.Dedent(`
				cmd: echo ok
				checksum:
				  persist: true
				`),
				// Adjust this substring to match actual error wording
				wantErrSubstring: "checksum.persist requires",
			},
			{
				name: "conflicting persist_checksum and checksum.persist",
				yamlText: dedent.Dedent(`
				cmd: echo ok
				persist_checksum: true
				checksum:
				  files:
				    - foo.txt
				  persist: false
				`),
				// Adjust this substring to match actual error wording
				wantErrSubstring: "conflict" ,
			},
		}

		for _, tt := range tests {
			t.Run(tt.name, func(t *testing.T) {
				var cmd Command

				err := yaml.Unmarshal([]byte(tt.yamlText), &cmd)
				if err == nil {
					t.Fatalf("expected error for invalid checksum configuration, got nil")
				}

				if tt.wantErrSubstring != "" && !strings.Contains(err.Error(), tt.wantErrSubstring) {
					t.Fatalf("expected error to contain %q, got %q", tt.wantErrSubstring, err.Error())
				}
			})
		}
	})
}
  1. Ensure the type name Command in the new test matches the actual type that implements UnmarshalYAML for commands. If your type is named differently (e.g. command, CommandConfig), update the declaration var cmd Command accordingly.
  2. The wantErrSubstring values are placeholders based on typical error messages; align each substring with the exact error strings returned by your UnmarshalYAML implementation so the assertions are stable.
  3. If UnmarshalYAML is implemented on a wrapper struct (e.g. a higher-level config object containing commands), you may need to unmarshal into that type instead and adjust the YAML snippets to match the surrounding structure.

Comment thread docs/docs/config.md Outdated
Comment thread docs/docs/advanced_usage.md Outdated
Comment thread internal/checksum/checksum.go Outdated
kindermax and others added 4 commits July 29, 2026 10:22
CI installs the latest released lets before running project commands. Keep the repo test-bats command on checksum syntax that the released binary can parse while this branch adds the new checksum.persist syntax.
Document checksum.sh as an intentional Project config execution boundary and suppress the gosec audit at that call site. Also pass the effective command work_dir into checksum calculation while preserving Project config workdir fallback for commands without an explicit work_dir.
Co-authored-by: sourcery-ai[bot] <58596630+sourcery-ai[bot]@users.noreply.github.com>
Co-authored-by: sourcery-ai[bot] <58596630+sourcery-ai[bot]@users.noreply.github.com>
Preserve config file modes when applying migrations and add coverage for invalid checksum configuration validation branches.
@kindermax
kindermax merged commit 7c2c34b into master Jul 29, 2026
5 checks passed
@kindermax
kindermax deleted the codex/redesign-checksums-and-migration branch July 29, 2026 10:01
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant