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
15 changes: 14 additions & 1 deletion AGENTS.md
Original file line number Diff line number Diff line change
Expand Up @@ -92,6 +92,7 @@ Use `context.Context` as the first parameter for functions that perform I/O or c

- Document all exported types, functions, and constants with Go doc comments.
- Ensure that the comments convey the meaning behind the code, not just the what.
- All comments must end with a full stop, including inline comments and multi-line comments.

**Example:**

Expand Down Expand Up @@ -233,7 +234,7 @@ All Go tests should be written in one of two ways:
#### General Rules

- Always call `t.Parallel()` at the top of every test function and within each subtest, unless:
- Its an integration test (files ending in `_integration_test.go`).
- It's an integration test (files ending in `_integration_test.go`).
- It performs file I/O, shell commands, or interacts with SOPS or the OS files
- Has the potential to fail with `--race` .
- Always use `t.Context()` when a `context.Context` is required in tests instead of
Expand All @@ -250,6 +251,18 @@ All Go tests should be written in one of two ways:
- If 100% coverage is not possible, explain _why_ in a brief note above the test function (no inline
comments).

#### Test Organisation

- **One test function per exported function/method** — add new test cases as subtests within the
existing test function rather than creating separate test functions.
- Only create a new test function if:
- Testing a distinctly different aspect that warrants complete separation (e.g.,
`TestTracker_Add` vs `TestTracker_Save`).
- The original test function would become unwieldy (>200 lines) with the addition.
- Group related test cases using descriptive subtest names that explain what's being tested.
- Aim for comprehensive coverage within each test function rather than fragmenting tests across
multiple functions.

#### Test Tables

The test should be:
Expand Down
15 changes: 13 additions & 2 deletions bin/install.sh
Original file line number Diff line number Diff line change
Expand Up @@ -61,10 +61,19 @@ detect_arch() {

# Get latest release version from GitHub
get_latest_version() {
LATEST_VERSION=$(curl -sSL "https://api.github.com/repos/$REPO/releases/latest" | grep '"tag_name":' | sed -E 's/.*"([^"]+)".*/\1/')
info "Fetching latest release information from GitHub..."

# Try to get the latest release using GitHub API
API_RESPONSE=$(curl -sSL "https://api.github.com/repos/$REPO/releases/latest" 2>&1)

if [ $? -ne 0 ]; then
error "Failed to connect to GitHub API. Check your internet connection."
fi

LATEST_VERSION=$(echo "$API_RESPONSE" | grep '"tag_name":' | sed -E 's/.*"([^"]+)".*/\1/' | head -n 1)

if [ -z "$LATEST_VERSION" ]; then
error "Failed to fetch latest version"
error "Failed to fetch latest version. API response may be rate-limited or no releases exist.\nTry setting VERSION environment variable explicitly: VERSION=v0.0.3 sh install.sh"
fi

echo "$LATEST_VERSION"
Expand All @@ -83,6 +92,7 @@ install_webkit() {
info "Installing webkit $VERSION for $OS/$ARCH..."

# Construct download URL and file extension
# Note: This naming must match GoReleaser's archive naming template
BINARY_NAME="webkit_${OS}_${ARCH}"

if [ "$OS" = "windows" ]; then
Expand All @@ -94,6 +104,7 @@ install_webkit() {
ARCHIVE_NAME="${BINARY_NAME}${ARCHIVE_EXT}"
DOWNLOAD_URL="https://github.com/$REPO/releases/download/$VERSION/$ARCHIVE_NAME"

info "Archive name: $ARCHIVE_NAME"
info "Downloading from: $DOWNLOAD_URL"

# Create temporary directory
Expand Down
13 changes: 9 additions & 4 deletions internal/cmd/update.go
Original file line number Diff line number Diff line change
Expand Up @@ -59,26 +59,31 @@ func update(ctx context.Context, input cmdtools.CommandInput) error {
printer.Info("Updating project dependencies...")
printer.LineBreak()

// 1. Load previous manifest
// 1. Load previous manifest.
oldManifest, err := manifest.Load(input.FS)
if err != nil && !errors.Is(err, manifest.ErrNoManifest) {
return errors.Wrap(err, "loading manifest")
}

// 2. Generate all files (they auto-track to new manifest)
// 2. Configure tracker to preserve timestamps for unchanged files.
if oldManifest != nil {
input.Manifest.WithPreviousManifest(oldManifest)
}

// 3. Generate all files (they auto-track to new manifest).
for _, op := range updateOps {
printer.Printf("🏃 %v\n", op.name)
if err = op.command(ctx, input); err != nil {
return err
}
}

// 3. Save new manifest
// 4. Save new manifest.
if err = input.Manifest.Save(input.FS); err != nil {
return errors.Wrap(err, "saving manifest")
}

// 4. Cleanup orphaned files
// 5. Cleanup orphaned files.
if oldManifest != nil {
newManifest, err := manifest.Load(input.FS)
if err != nil {
Expand Down
69 changes: 65 additions & 4 deletions internal/manifest/tracker.go
Original file line number Diff line number Diff line change
Expand Up @@ -16,12 +16,15 @@ import (
// Tracker maintains a collection of generated files and their metadata.
// Used to track which files were created by webkit and from which source.
type Tracker struct {
files map[string]FileEntry
mtx *sync.Mutex
marshaller func(v any, prefix, indent string) ([]byte, error)
files map[string]FileEntry
previousManifest *Manifest
mtx *sync.Mutex
marshaller func(v any, prefix, indent string) ([]byte, error)
}

// NewTracker creates a tracker with an initialized file map.
// If a previous manifest is provided, it will be used to preserve timestamps
// for files that haven't changed.
func NewTracker() *Tracker {
return &Tracker{
files: make(map[string]FileEntry),
Expand All @@ -30,23 +33,52 @@ func NewTracker() *Tracker {
}
}

// WithPreviousManifest sets the previous manifest for timestamp preservation.
func (t *Tracker) WithPreviousManifest(previous *Manifest) *Tracker {
t.previousManifest = previous
return t
}

// Path defines the filepath where the manifest resides.
var Path = filepath.Join(".webkit", "manifest.json")

// Add stores a file entry in the tracker, keyed by its path.
// If an entry with the same path exists, it will be overwritten.
// If the previous manifest contains the same file with the same hash,
// the GeneratedAt timestamp will be preserved.
func (t *Tracker) Add(entry FileEntry) {
t.mtx.Lock()
defer t.mtx.Unlock()

// Check if we have a previous manifest and if this file existed before
if t.previousManifest != nil {
if previousEntry, exists := t.previousManifest.Files[entry.Path]; exists {
// If the content hash hasn't changed, preserve the previous timestamp
if previousEntry.Hash == entry.Hash {
entry.GeneratedAt = previousEntry.GeneratedAt
}
}
}

t.files[entry.Path] = entry
}

// Save writes the tracker's files to a manifest JSON file.
// Creates parent directories if they don't exist.
// If a previous manifest was provided and no files have changed,
// the manifest's GeneratedAt timestamp will be preserved.
func (t *Tracker) Save(fs afero.Fs) error {
generatedAt := time.Now()

// If we have a previous manifest, check if any files actually changed
if t.previousManifest != nil && t.hasFilesChanged() == false {
// No changes detected, preserve the previous manifest's timestamp
generatedAt = t.previousManifest.GeneratedAt
}

manifest := Manifest{
Version: version.Version,
GeneratedAt: time.Now(),
GeneratedAt: generatedAt,
Files: t.files,
}

Expand All @@ -63,6 +95,35 @@ func (t *Tracker) Save(fs afero.Fs) error {
return afero.WriteFile(fs, Path, data, 0o644)
}

// hasFilesChanged checks if any files have different timestamps
// compared to the previous manifest, indicating actual changes.
func (t *Tracker) hasFilesChanged() bool {
if t.previousManifest == nil {
return true
}

// Check if file count differs.
if len(t.files) != len(t.previousManifest.Files) {
return true
}

// Check if any file has a different GeneratedAt timestamp.
// (which would have been updated by Add if the hash changed).
for path, newEntry := range t.files {
previousEntry, exists := t.previousManifest.Files[path]
if !exists {
return true
}
// If the timestamp was preserved, they'll be equal.
// If it was updated, they'll be different.
if !newEntry.GeneratedAt.Equal(previousEntry.GeneratedAt) {
return true
}
}

return false
}

// ErrNoManifest is returned by Load() when there hasen't been
// a manifest generated yet.
var ErrNoManifest = fmt.Errorf("no manifest found")
Expand Down
Loading
Loading