Skip to content

[FEATURE] Add model-aware response cache service with AppData storage and 30-day TTL #222

@guibranco

Description

@guibranco

We need a reusable and testable caching layer for commit message generation results from the OpenAI API.

The goal is to store and reuse results for identical inputs, using a hash of the model name, branch, author message, and diff content to ensure consistent results across sessions. Cached responses should:

  • Be stored in a platform-agnostic location under the user's AppData/config directory
  • Use a model-aware SHA256 hash for lookup
  • Include a checksum for validation
  • Have a 30-day TTL
  • Be pluggable via an ICacheProvider interface
  • Allow override of cache location via environment variable (COMMIT_CACHE_PATH)

Proposed Implementation:

ICacheProvider.cs

public interface ICacheProvider
{
    string GenerateHash(string model, string branch, string authorMessage, string diff);
    Task<string?> LoadAsync(string model, string hash, int maxAgeDays = 30);
    Task SaveAsync(string model, string hash, string response);
}

🗂️ FileCacheProvider.cs (cross-platform version)

using System.Security.Cryptography;
using System.Text;
using System.Text.Json;

public class FileCacheProvider : ICacheProvider
{
    private record CachedResponse(string Model, string Response, string Checksum, DateTime Timestamp);

    private readonly string _cacheDir;

    public FileCacheProvider(string appName = "CommitMessageTool")
    {
        string? envPath = Environment.GetEnvironmentVariable("COMMIT_CACHE_PATH");
        if (!string.IsNullOrWhiteSpace(envPath))
        {
            _cacheDir = Path.Combine(envPath, appName, "commit-cache");
        }
        else
        {
            string baseDir = Environment.GetFolderPath(Environment.SpecialFolder.ApplicationData);

            // Fallback for non-Windows
            if (string.IsNullOrWhiteSpace(baseDir))
            {
                string? home = Environment.GetEnvironmentVariable("HOME")
                            ?? Environment.GetEnvironmentVariable("USERPROFILE");

                if (string.IsNullOrEmpty(home))
                    throw new InvalidOperationException("Unable to determine a valid user data directory");

                baseDir = Path.Combine(home, ".config");
            }

            _cacheDir = Path.Combine(baseDir, appName, "commit-cache");
        }

        Directory.CreateDirectory(_cacheDir);
    }

    public string GenerateHash(string model, string branch, string authorMessage, string diff)
    {
        string combined = $"{model}|{branch}|{authorMessage}|{diff}";
        using var sha = SHA256.Create();
        byte[] hashBytes = sha.ComputeHash(Encoding.UTF8.GetBytes(combined));
        return BitConverter.ToString(hashBytes).Replace("-", "").ToLowerInvariant();
    }

    public async Task<string?> LoadAsync(string model, string hash, int maxAgeDays = 30)
    {
        string path = Path.Combine(_cacheDir, $"{hash}.json");
        if (!File.Exists(path)) return null;

        var json = await File.ReadAllTextAsync(path);
        var cached = JsonSerializer.Deserialize<CachedResponse>(json);

        if (cached is null || cached.Model != model)
            return null;

        bool expired = DateTime.UtcNow - cached.Timestamp > TimeSpan.FromDays(maxAgeDays);
        bool validChecksum = cached.Checksum == ComputeChecksum(model, cached.Response);

        if (expired || !validChecksum)
        {
            File.Delete(path);
            return null;
        }

        return cached.Response;
    }

    public async Task SaveAsync(string model, string hash, string response)
    {
        string path = Path.Combine(_cacheDir, $"{hash}.json");
        var checksum = ComputeChecksum(model, response);
        var cached = new CachedResponse(model, response, checksum, DateTime.UtcNow);
        var json = JsonSerializer.Serialize(cached);
        await File.WriteAllTextAsync(path, json);
    }

    private string ComputeChecksum(string model, string content)
    {
        using var sha = SHA256.Create();
        var combined = $"{model}|{content}";
        byte[] hashBytes = sha.ComputeHash(Encoding.UTF8.GetBytes(combined));
        return BitConverter.ToString(hashBytes).Replace("-", "").ToLowerInvariant();
    }
}

🧠 CommitMessageCacheService.cs

public class CommitMessageCacheService
{
    private readonly ICacheProvider _cacheProvider;

    public CommitMessageCacheService(ICacheProvider cacheProvider)
    {
        _cacheProvider = cacheProvider;
    }

    public async Task<string> GetOrGenerateAsync(
        string model,
        string branch,
        string authorMessage,
        string diff,
        Func<Task<string>> generateFunc)
    {
        string hash = _cacheProvider.GenerateHash(model, branch, authorMessage, diff);
        string? cached = await _cacheProvider.LoadAsync(model, hash);

        if (cached != null)
        {
            Console.WriteLine("✅ Loaded from cache.");
            return cached;
        }

        string result = await generateFunc();
        await _cacheProvider.SaveAsync(model, hash, result);
        Console.WriteLine("💬 Cached new result.");
        return result;
    }
}

🧪 Example usage

var cacheProvider = new FileCacheProvider("YourAppName");
var cacheService = new CommitMessageCacheService(cacheProvider);

string result = await cacheService.GetOrGenerateAsync(
    model: "gpt-4-turbo",
    branch: "feature/user-auth",
    authorMessage: "add login endpoint",
    diff: yourDiffContent,
    generateFunc: () => CallOpenAiApiAsync(...)
);

Console.WriteLine(result);

Acceptance Criteria:

  • ICacheProvider interface defined with GenerateHash, LoadAsync, SaveAsync
  • FileCacheProvider is cross-platform (Windows/macOS/Linux)
  • Fallback to $HOME/.config if ApplicationData is empty
  • Optional override via COMMIT_CACHE_PATH env var
  • 30-day TTL is applied and expired files are discarded
  • Checksum includes model name for integrity
  • CommitMessageCacheService orchestrates cache lookup, call, and save
  • Example usage added to project docs/tests

Metadata

Metadata

Assignees

No one assigned

    Labels

    enhancementNew feature or requestgood first issueGood for newcomershacktoberfestParticipation in the Hacktoberfest eventhelp wantedExtra attention is needed👷🏼 infrastructureInfrastructure-related tasks or issues📝 documentationTasks related to writing or updating documentation🕔 high effortA task that can be completed in a few days🧪 testsTasks related to testing

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions