2 Commits
Author SHA1 Message Date
sneak 53926fbea5 Re-vendor the canonical files from sneak/prompts at dd4027b (closes #213)
check / check (push) Successful in 13m48s
Linting and testing become the lint and test phases of the Dockerfile,
and the build stage depends on both. Dockerfile.lint, CHECK_EPOCH and
the tests that checked them are removed. Every docker build in script/
passes --no-cache, and script/cibuild runs script/bootstrap first. A
host without Go gets the go.mod version from script/install-go in
.tool/go, which bootstrap, the Makefile, fmt, fmt-check, precommit and
release add to PATH; fmt-check skips .tool. The image takes its version
from the VERSION build arg or git describe, dev without .git. This
repo's own entries follow the canonical content in .gitignore and
.editorconfig. The golangci-lint v2.14.0 findings are fixed. The rules
in CLAUDE.md move into AGENTS.md. IsDevVersion counts "unknown".

Model: opus-5-5
2026-10-06 09:32:09 +00:00
clawbot c4adb72d80 Run the local index in WAL mode with a busy timeout (closes #217)
check / check (push) Successful in 5m10s
check / check (pull_request) Successful in 5m38s
The connection settings were passed as `_journal_mode=`-style
parameters, which the SQLite driver drops without an error, so the
index ran in rollback-journal mode with no busy timeout. `snapshot
list` or `info` reading during a backup could make the backup's next
write fail with "database is locked". Both open paths now pass
`_pragma=` parameters; foreign keys moved there too.

With WAL on, rows committed to the open index can still be in the
-wal file, which a copy of the main file misses. The metadata export
now copies the index with VACUUM INTO, into an empty 0600 file.

The retry after a failed open no longer claims a TRUNCATE recovery; it
retries with the same settings.

Model: opus-5-5
2026-10-06 11:29:18 +02:00
11 changed files with 264 additions and 114 deletions
+4 -4
View File
@@ -765,8 +765,9 @@ them. We provide:
* `script/bootstrap` — install all development dependencies (Go, Go * `script/bootstrap` — install all development dependencies (Go, Go
module download). A host without Go gets the `go.mod` version through module download). A host without Go gets the `go.mod` version through
`script/install-go`, in `.tool/go`, which the `Makefile`, `script/fmt`, `script/install-go`, in `.tool/go`, which `script/bootstrap` itself,
`script/fmt-check` and `script/precommit` add to their `PATH`. It the `Makefile`, `script/fmt`, `script/fmt-check`, `script/precommit`
and `script/release` add to their `PATH`. It
deliberately does not install `golangci-lint`; see `script/lint` deliberately does not install `golangci-lint`; see `script/lint`
below. below.
* `script/setup` — make a fresh clone ready for development: runs * `script/setup` — make a fresh clone ready for development: runs
@@ -818,8 +819,7 @@ them. We provide:
find out whether the tree is clean. find out whether the tree is clean.
* `script/fmt` — format all code (writes) * `script/fmt` — format all code (writes)
* `script/fmt-check` — check formatting (read-only). It runs `gofmt` on * `script/fmt-check` — check formatting (read-only). It runs `gofmt` on
the host, over the Go files git lists, and fails outside a git the host, over every Go file outside `.tool`.
checkout.
* `script/check` — run `script/test`, `script/lint`, and * `script/check` — run `script/test`, `script/lint`, and
`script/fmt-check`. `script/fmt-check`.
* `script/docker` — build the Docker image tagged via * `script/docker` — build the Docker image tagged via
+9
View File
@@ -31,6 +31,15 @@ the tag exists and is exercised; what is left is merging `next` to
is v2.14.0, with its new findings fixed in the code, and the rules in is v2.14.0, with its new findings fixed in the code, and the rules in
`CLAUDE.md` now live in `AGENTS.md`. `CLAUDE.md` now live in `AGENTS.md`.
- 2026-10-06: Made the local index actually run in WAL mode with a busy
timeout ([issue #217](https://git.eeqj.de/sneak/vaultik/issues/217)).
The connection settings were written in a form the SQLite driver
ignores, so the index ran without either and `snapshot list` or `info`
during a backup could make the backup's next write fail with
`database is locked`. They are now `_pragma=` parameters, and the
metadata export copies the open index with `VACUUM INTO`, because a
copy of the file alone misses rows still in the `-wal` file.
- 2026-10-06: Made a backup record the real uid and gid of files, - 2026-10-06: Made a backup record the real uid and gid of files,
directories and symlinks directories and symlinks
([issue #216](https://git.eeqj.de/sneak/vaultik/issues/216)). The ([issue #216](https://git.eeqj.de/sneak/vaultik/issues/216)). The
+1 -1
View File
@@ -280,7 +280,7 @@ This ensures consistency, especially important for operations like:
3. **Batch Operations**: Where possible, operations are batched within transactions 3. **Batch Operations**: Where possible, operations are batched within transactions
4. **Write-Ahead Logging**: SQLite WAL mode is enabled for better concurrency 4. **Write-Ahead Logging**: The local index runs in SQLite WAL mode with a 10-second busy timeout, so a read-only command such as `snapshot list` can read it while a backup writes to it. Committed rows can sit in the `-wal` file beside the index until a checkpoint, so the metadata export copies the index through SQLite (`VACUUM INTO`), not as a file
## Data Integrity ## Data Integrity
+39 -44
View File
@@ -42,6 +42,10 @@ var schemaFS embed.FS
// table itself. It is applied before the normal migration loop. // table itself. It is applied before the normal migration loop.
const bootstrapVersion = 0 const bootstrapVersion = 0
// busyTimeoutMs is how long a connection to the index waits for another
// connection's lock before failing with "database is locked".
const busyTimeoutMs = 10000
// DB represents the Vaultik local index database connection. // DB represents the Vaultik local index database connection.
// It uses SQLite to track file metadata, content-defined chunks, and blob associations. // It uses SQLite to track file metadata, content-defined chunks, and blob associations.
// The database enables incremental backups by detecting changed files and // The database enables incremental backups by detecting changed files and
@@ -94,10 +98,27 @@ func ParseMigrationVersion(filename string) (int, error) {
return version, nil return version, nil
} }
// indexDSN returns the driver DSN that opens the database at path. The
// driver runs each _pragma parameter on every connection it opens and drops
// any parameter it does not know without an error, so a setting written in
// another form silently does nothing. In WAL mode one connection can write
// while others read; the busy timeout makes a connection wait for a lock
// instead of failing at once.
func indexDSN(path string) string {
return fmt.Sprintf(
"%s?_pragma=busy_timeout(%d)&_pragma=journal_mode(WAL)"+
"&_pragma=synchronous(NORMAL)&_pragma=foreign_keys(1)",
path, busyTimeoutMs)
}
// New creates a new database connection at the specified path. // New creates a new database connection at the specified path.
// It creates the schema if needed and configures SQLite with WAL mode for // It creates the schema if needed. Every connection runs in WAL mode with
// better concurrency. SQLite handles crash recovery automatically when // a busy timeout and foreign keys on (see indexDSN), so a read-only command
// opening a database with journal/WAL files present. // can read the index while a backup writes to it. Committed rows can sit in
// the -wal file beside the database until a checkpoint, so a copy of the
// database file alone may miss them.
// SQLite handles crash recovery automatically when opening a database with
// journal/WAL files present.
// The path parameter can be a file path for persistent storage or ":memory:" // The path parameter can be a file path for persistent storage or ":memory:"
// for an in-memory database (useful for testing). // for an in-memory database (useful for testing).
func New(ctx context.Context, path string) (*DB, error) { func New(ctx context.Context, path string) (*DB, error) {
@@ -110,11 +131,7 @@ func New(ctx context.Context, path string) (*DB, error) {
// First attempt with standard WAL mode // First attempt with standard WAL mode
log.Debug("Attempting to open database with WAL mode", "path", path) log.Debug("Attempting to open database with WAL mode", "path", path)
conn, err := sql.Open( conn, err := sql.Open("sqlite", indexDSN(path))
"sqlite",
path+"?_journal_mode=WAL&_synchronous=NORMAL&_busy_timeout=10000"+
"&_locking_mode=NORMAL&_foreign_keys=ON",
)
if err == nil { if err == nil {
configureConnPool(conn) configureConnPool(conn)
@@ -134,8 +151,8 @@ func New(ctx context.Context, path string) (*DB, error) {
_ = conn.Close() _ = conn.Close()
} }
// If first attempt failed, try with TRUNCATE mode to clear any locks // If the first attempt failed, try once more
return openWithRecovery(ctx, path) return retryOpen(ctx, path)
} }
// configureConnPool serializes all database access through one connection. // configureConnPool serializes all database access through one connection.
@@ -147,18 +164,12 @@ func configureConnPool(conn *sql.DB) {
conn.SetMaxIdleConns(1) conn.SetMaxIdleConns(1)
} }
// finishOpen enables foreign keys, wraps the connection, and applies any // finishOpen wraps the connection and applies any pending migrations. On
// pending migrations. On migration failure the connection is closed. // migration failure the connection is closed.
func finishOpen(ctx context.Context, conn *sql.DB, path string) (*DB, error) { func finishOpen(ctx context.Context, conn *sql.DB, path string) (*DB, error) {
// Enable foreign keys explicitly
_, err := conn.ExecContext(ctx, "PRAGMA foreign_keys = ON")
if err != nil {
log.Warn("Failed to enable foreign keys", "path", path, "error", err)
}
db := &DB{conn: conn, path: path} db := &DB{conn: conn, path: path}
err = applyMigrations(ctx, conn) err := applyMigrations(ctx, conn)
if err != nil { if err != nil {
_ = conn.Close() _ = conn.Close()
@@ -168,21 +179,15 @@ func finishOpen(ctx context.Context, conn *sql.DB, path string) (*DB, error) {
return db, nil return db, nil
} }
// openWithRecovery retries opening the database in TRUNCATE journal mode to // retryOpen makes a second attempt to open the database, with the same
// clear stale locks, then switches back to WAL mode. // settings, after the first attempt failed, for example because another
func openWithRecovery(ctx context.Context, path string) (*DB, error) { // process held a lock for longer than the busy timeout.
log.Info( func retryOpen(ctx context.Context, path string) (*DB, error) {
"Database appears locked, attempting recovery with TRUNCATE mode", log.Info("Database appears locked, retrying open", "path", path)
"path", path,
)
conn, err := sql.Open( conn, err := sql.Open("sqlite", indexDSN(path))
"sqlite",
path+"?_journal_mode=TRUNCATE&_synchronous=NORMAL&_busy_timeout=10000"+
"&_foreign_keys=ON",
)
if err != nil { if err != nil {
return nil, fmt.Errorf("opening database in recovery mode: %w", err) return nil, fmt.Errorf("opening database on retry: %w", err)
} }
configureConnPool(conn) configureConnPool(conn)
@@ -190,28 +195,18 @@ func openWithRecovery(ctx context.Context, path string) (*DB, error) {
err = conn.PingContext(ctx) err = conn.PingContext(ctx)
if err != nil { if err != nil {
log.Debug( log.Debug(
"Failed to ping database in recovery mode, closing", "Failed to ping database on retry, closing",
"path", path, "error", err, "path", path, "error", err,
) )
_ = conn.Close() _ = conn.Close()
return nil, fmt.Errorf( return nil, fmt.Errorf(
"database still locked after recovery attempt: %w", "database still locked on retry: %w",
err, err,
) )
} }
log.Debug("Database opened in TRUNCATE mode", "path", path)
// Switch back to WAL mode
log.Debug("Switching database back to WAL mode", "path", path)
_, err = conn.ExecContext(ctx, "PRAGMA journal_mode=WAL")
if err != nil {
log.Warn("Failed to switch back to WAL mode", "path", path, "error", err)
}
db, err := finishOpen(ctx, conn, path) db, err := finishOpen(ctx, conn, path)
if err != nil { if err != nil {
return nil, err return nil, err
+83
View File
@@ -120,6 +120,89 @@ func TestDatabaseConcurrentAccess(t *testing.T) {
} }
} }
// TestNewSetsJournalModeAndBusyTimeout checks that the connection settings
// New passes reach SQLite. The driver drops a setting it does not recognise
// without an error, so only reading the value back shows it took effect.
func TestNewSetsJournalModeAndBusyTimeout(t *testing.T) {
t.Parallel()
ctx := context.Background()
db, err := New(ctx, filepath.Join(t.TempDir(), "index.db"))
if err != nil {
t.Fatalf("failed to create database: %v", err)
}
defer func() { _ = db.Close() }()
var journalMode string
err = db.conn.QueryRowContext(ctx, "PRAGMA journal_mode").Scan(&journalMode)
if err != nil {
t.Fatalf("reading journal_mode: %v", err)
}
if journalMode != "wal" {
t.Errorf("journal_mode = %q, want %q", journalMode, "wal")
}
var busyTimeout int
err = db.conn.QueryRowContext(ctx, "PRAGMA busy_timeout").Scan(&busyTimeout)
if err != nil {
t.Fatalf("reading busy_timeout: %v", err)
}
if busyTimeout != busyTimeoutMs {
t.Errorf("busy_timeout = %d, want %d", busyTimeout, busyTimeoutMs)
}
}
// TestNewWriteSucceedsWhileAnotherHandleReads opens the same index twice,
// as a read-only command does while a backup runs, and checks that a write
// on one handle commits while the other is in the middle of a read.
func TestNewWriteSucceedsWhileAnotherHandleReads(t *testing.T) {
t.Parallel()
ctx := context.Background()
dbPath := filepath.Join(t.TempDir(), "index.db")
reader, err := New(ctx, dbPath)
if err != nil {
t.Fatalf("failed to open reading handle: %v", err)
}
defer func() { _ = reader.Close() }()
writer, err := New(ctx, dbPath)
if err != nil {
t.Fatalf("failed to open writing handle: %v", err)
}
defer func() { _ = writer.Close() }()
// The read lock taken by the SELECT is held until the transaction ends.
readTx, err := reader.BeginTx(ctx, nil)
if err != nil {
t.Fatalf("beginning read transaction: %v", err)
}
defer func() { _ = readTx.Rollback() }()
var count int
err = readTx.QueryRowContext(ctx, "SELECT COUNT(*) FROM chunks").Scan(&count)
if err != nil {
t.Fatalf("reading chunks: %v", err)
}
_, err = writer.ExecWithLog(ctx,
"INSERT INTO chunks (chunk_hash, size) VALUES (?, ?)", "hash", 1024)
if err != nil {
t.Fatalf("write while another handle reads: %v", err)
}
}
func TestParseMigrationVersion(t *testing.T) { func TestParseMigrationVersion(t *testing.T) {
t.Parallel() t.Parallel()
+16 -8
View File
@@ -1,40 +1,48 @@
//nolint:testpackage // exercises the unexported copyFile helper //nolint:testpackage // exercises the unexported copyDatabase helper
package snapshot package snapshot
import ( import (
"context"
"os" "os"
"path/filepath" "path/filepath"
"syscall" "syscall"
"testing" "testing"
"github.com/spf13/afero" "github.com/spf13/afero"
"sneak.berlin/go/vaultik/internal/database"
) )
// TestCopyFileExportCopyMode verifies that the exported snapshot database // TestCopyDatabaseExportCopyMode verifies that the exported snapshot
// copy is created owner-only (0600), even under a lenient 022 umask that // database copy is created owner-only (0600), even under a lenient 022
// would otherwise leave a fresh file world-readable. // umask that would otherwise leave a fresh file world-readable.
// //
//nolint:paralleltest // syscall.Umask is process-global; parallel tests would clash //nolint:paralleltest // syscall.Umask is process-global; parallel tests would clash
func TestCopyFileExportCopyMode(t *testing.T) { func TestCopyDatabaseExportCopyMode(t *testing.T) {
restore := syscall.Umask(0o022) restore := syscall.Umask(0o022)
defer syscall.Umask(restore) defer syscall.Umask(restore)
ctx := context.Background()
dir := t.TempDir() dir := t.TempDir()
src := filepath.Join(dir, "index.sqlite") src := filepath.Join(dir, "index.sqlite")
err := os.WriteFile(src, []byte("index data"), 0o600) db, err := database.New(ctx, src)
if err != nil { if err != nil {
t.Fatalf("creating source index: %v", err) t.Fatalf("creating source index: %v", err)
} }
err = db.Close()
if err != nil {
t.Fatalf("closing source index: %v", err)
}
dst := filepath.Join(dir, "snapshot.db") dst := filepath.Join(dir, "snapshot.db")
sm := &SnapshotManager{fs: afero.NewOsFs()} sm := &SnapshotManager{fs: afero.NewOsFs()}
err = sm.copyFile(src, dst) err = sm.copyDatabase(ctx, src, dst)
if err != nil { if err != nil {
t.Fatalf("copyFile: %v", err) t.Fatalf("copyDatabase: %v", err)
} }
info, err := os.Stat(dst) info, err := os.Stat(dst)
+33 -38
View File
@@ -386,12 +386,12 @@ func (sm *SnapshotManager) prepareExportDB(
ctx context.Context, dbPath, snapshotID, tempDir string, ctx context.Context, dbPath, snapshotID, tempDir string,
) ([]byte, string, error) { ) ([]byte, string, error) {
// Step 1: Copy database to temp file // Step 1: Copy database to temp file
// The main database should be closed at this point // The main database is still open here, so it is copied through SQLite
tempDBPath := filepath.Join(tempDir, "snapshot.db") tempDBPath := filepath.Join(tempDir, "snapshot.db")
log.Debug("Copying database to temporary location", log.Debug("Copying database to temporary location",
"source", dbPath, "destination", tempDBPath) "source", dbPath, "destination", tempDBPath)
err := sm.copyFile(dbPath, tempDBPath) err := sm.copyDatabase(ctx, dbPath, tempDBPath)
if err != nil { if err != nil {
return nil, "", fmt.Errorf("copying database: %w", err) return nil, "", fmt.Errorf("copying database: %w", err)
} }
@@ -648,9 +648,10 @@ func (sm *SnapshotManager) collectCleanupStats(
// //
// VACUUM runs through the modernc.org/sqlite driver, on a freshly opened // VACUUM runs through the modernc.org/sqlite driver, on a freshly opened
// connection with no transaction in flight (VACUUM cannot run inside one). // connection with no transaction in flight (VACUUM cannot run inside one).
// The database opens in WAL mode, so VACUUM's rewrite lands in the WAL; the // database.New opens the file in WAL mode, so VACUUM's rewrite lands in the
// checkpoint on Close flushes it into the main file, which is the file we // -wal file. This is the only connection to the file, so closing it
// then compress and upload. // checkpoints the rewrite into the main file and removes the -wal file; the
// main file is the one compressFile then compresses and uploads.
func (sm *SnapshotManager) vacuumDatabase(ctx context.Context, dbPath string) error { func (sm *SnapshotManager) vacuumDatabase(ctx context.Context, dbPath string) error {
log.Debug("Running VACUUM on database", "path", dbPath) log.Debug("Running VACUUM on database", "path", dbPath)
@@ -744,26 +745,15 @@ func (sm *SnapshotManager) compressFile(inputPath, outputPath string) error {
// user; it holds the same private index data as the local index file. // user; it holds the same private index data as the local index file.
const exportCopyPerm = 0o600 const exportCopyPerm = 0o600
// copyFile copies a file from src to dst. The destination is the exported // copyDatabase copies the database at src to dst with VACUUM INTO. It reads
// snapshot database, so it is created owner-only rather than with the // through SQLite, so the copy holds rows committed to src that are still in
// umask-dependent default. // its -wal file, which a copy of the file alone would miss. The destination
func (sm *SnapshotManager) copyFile(src, dst string) error { // is the exported snapshot database, so it is created empty and owner-only
log.Debug("Opening source file for copy", "path", src) // first rather than with the umask-dependent default: VACUUM INTO writes
// into an existing empty file and keeps its mode.
sourceFile, err := sm.fs.Open(src) func (sm *SnapshotManager) copyDatabase(
if err != nil { ctx context.Context, src, dst string,
return err ) error {
}
defer func() {
log.Debug("Closing source file", "path", src)
err := sourceFile.Close()
if err != nil {
log.Debug("Failed to close source file", "path", src, "error", err)
}
}()
log.Debug("Creating destination file", "path", dst) log.Debug("Creating destination file", "path", dst)
destFile, err := sm.fs.OpenFile( destFile, err := sm.fs.OpenFile(
@@ -773,23 +763,28 @@ func (sm *SnapshotManager) copyFile(src, dst string) error {
return err return err
} }
defer func() { err = destFile.Close()
log.Debug("Closing destination file", "path", dst)
err := destFile.Close()
if err != nil {
log.Debug("Failed to close destination file", "path", dst, "error", err)
}
}()
log.Debug("Copying file data")
n, err := io.Copy(destFile, sourceFile)
if err != nil { if err != nil {
return err return err
} }
log.Debug("File copy complete", "bytes_copied", n) db, err := database.New(ctx, src)
if err != nil {
return fmt.Errorf("opening database to copy: %w", err)
}
defer func() {
cerr := db.Close()
if cerr != nil {
log.Debug("Failed to close database after copy",
"path", src, "error", cerr)
}
}()
_, err = db.ExecWithLog(ctx, "VACUUM INTO ?", dst)
if err != nil {
return fmt.Errorf("running VACUUM INTO: %w", err)
}
return nil return nil
} }
+71
View File
@@ -188,6 +188,77 @@ func TestVacuumDatabaseRemovesDeletedData(t *testing.T) {
} }
} }
// TestPrepareExportDBKeepsRowsCommittedToOpenIndex exports from an index
// that is still open, as a backup does. A row committed there can still be
// in the index's -wal file, and the export must hold it all the same.
func TestPrepareExportDBKeepsRowsCommittedToOpenIndex(t *testing.T) {
log.Initialize(log.Config{})
t.Parallel()
ctx := context.Background()
fs := afero.NewOsFs()
dbPath := filepath.Join(t.TempDir(), "index.sqlite")
db, err := database.New(ctx, dbPath)
if err != nil {
t.Fatalf("failed to create database: %v", err)
}
defer func() { _ = db.Close() }()
repos := database.NewRepositories(db)
snapshot := &database.Snapshot{ID: "open-index-snapshot", Hostname: "test-host"}
err = repos.WithTx(ctx, func(ctx context.Context, tx *sql.Tx) error {
return repos.Snapshots.Create(ctx, tx, snapshot)
})
if err != nil {
t.Fatalf("failed to create snapshot: %v", err)
}
sm := &SnapshotManager{
config: &config.Config{
CompressionLevel: 3,
AgeRecipients: []string{testAgeRecipient},
},
fs: fs,
}
_, tempDBPath, err := sm.prepareExportDB(
ctx, dbPath, snapshot.ID.String(), t.TempDir())
if err != nil {
t.Fatalf("prepareExportDB failed: %v", err)
}
// Only the main database file is compressed and uploaded, so open a
// copy of that file alone.
uploadedPath := filepath.Join(t.TempDir(), "uploaded.db")
err = copyFile(fs, tempDBPath, uploadedPath)
if err != nil {
t.Fatalf("failed to copy exported database: %v", err)
}
exported, err := database.OpenReadOnly(ctx, uploadedPath)
if err != nil {
t.Fatalf("failed to open exported database: %v", err)
}
defer func() { _ = exported.Close() }()
got, err := database.NewRepositories(exported).Snapshots.GetByID(
ctx, snapshot.ID.String())
if err != nil {
t.Fatalf("failed to read snapshot from export: %v", err)
}
if got == nil {
t.Fatal("exported database is missing the snapshot row")
}
}
func TestCleanSnapshotDBEmptySnapshot(t *testing.T) { func TestCleanSnapshotDBEmptySnapshot(t *testing.T) {
// Initialize logger // Initialize logger
log.Initialize(log.Config{}) log.Initialize(log.Config{})
+2 -2
View File
@@ -106,8 +106,8 @@ main() {
# Go toolchain: the host's own, or else the version go.mod names, # Go toolchain: the host's own, or else the version go.mod names,
# hash-verified, in .tool/go. That directory is not on the caller's # hash-verified, in .tool/go. That directory is not on the caller's
# PATH; the Makefile, script/fmt, script/fmt-check and # PATH; this script, the Makefile, script/fmt, script/fmt-check,
# script/precommit add it to theirs. # script/precommit and script/release add it to theirs.
if missing go; then if missing go; then
"$ROOT/script/install-go" "$ROOT/script/install-go"
PATH="$PATH:$ROOT/.tool/go/bin" PATH="$PATH:$ROOT/.tool/go/bin"
+4 -17
View File
@@ -9,25 +9,12 @@ main() {
cd "$ROOT" cd "$ROOT"
# Where script/bootstrap installs Go when the host has none. # Where script/bootstrap installs Go when the host has none.
PATH="$PATH:$ROOT/.tool/go/bin" PATH="$PATH:$ROOT/.tool/go/bin"
# The Go files git lists, which leaves out the sources of that # .tool/go holds that toolchain's own sources, which are not ours.
# toolchain in the ignored .tool/go. git still lists a tracked file if ! unformatted="$(find . -path ./.tool -prune -o -name '*.go' -type f \
# whose deletion is not staged yet, and gofmt fails on a missing file. -exec gofmt -l {} +)"; then
listed="$(git ls-files --cached --others --exclude-standard '*.go')" || { echo "fmt-check: gofmt failed" >&2
echo "fmt-check: git ls-files failed; run it in a git checkout" >&2
exit 1 exit 1
}
files=""
for f in $listed; do
if [ -e "$f" ]; then
files="$files $f"
fi
done
# gofmt with no file arguments reads standard input.
if [ -z "$files" ]; then
exit 0
fi fi
# shellcheck disable=SC2086 # one argument per file name
unformatted="$(gofmt -l $files)"
if [ -n "$unformatted" ]; then if [ -n "$unformatted" ]; then
echo "Files not formatted:" >&2 echo "Files not formatted:" >&2
echo "$unformatted" >&2 echo "$unformatted" >&2
+2
View File
@@ -44,6 +44,8 @@ resolve_goreleaser() {
main() { main() {
cd "$ROOT" cd "$ROOT"
# Where script/bootstrap installs Go when the host has none.
PATH="$PATH:$ROOT/.tool/go/bin"
if ! bin="$(resolve_goreleaser)"; then if ! bin="$(resolve_goreleaser)"; then
cat >&2 <<EOF cat >&2 <<EOF