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
5 changes: 3 additions & 2 deletions BACKLOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -2,8 +2,8 @@

## Sprint 6/11/2025 - 6/18/2025

- [ ] Landing page - adding interactive demos and examples
- [ ] BREAKING CHANGES - refactor cli pattern removing :dir options -> Needs to implement a new infrastructure for managing temp directories
- [x] Landing page - adding interactive demos and examples
- [x] BREAKING CHANGES - refactor cli pattern removing :dir options -> Needs to implement a new infrastructure for managing temp directories
- [x] Publish CLI tool to package managers (Homebrew for macOS)
- [x] Implement Code Age analysis methodology (identify stable vs volatile code based on age)
- [x] Add proper descriptions to README files in gitlock_cli and gitlock_core apps
Expand All @@ -13,6 +13,7 @@

- [ ] Design and implement database schema for SaaS features (users, projects, analysis history, subscription tiers, organizations)
- [ ] Set up app.js code structure for Hooks and interactive component
- [ ] Implement a git log cache system - improving performance on large repositories

## To Do

Expand Down
5 changes: 0 additions & 5 deletions apps/gitlock_core/lib/gitlock_core/adapters/vcs/git.ex
Original file line number Diff line number Diff line change
Expand Up @@ -35,11 +35,6 @@ defmodule GitlockCore.Adapters.VCS.Git do
end
end

# Delegate to GitRepository for backward compatibility with tests
def determine_source_type(source) do
GitRepository.determine_source_type(source)
end

# Make public for testing
def parse_git_log(log_content) when is_binary(log_content) do
cond do
Expand Down
172 changes: 106 additions & 66 deletions apps/gitlock_core/lib/gitlock_core/infrastructure/git_repository.ex
Original file line number Diff line number Diff line change
@@ -1,11 +1,13 @@
defmodule GitlockCore.Infrastructure.GitRepository do
@moduledoc """
Infrastructure for executing git commands and fetching logs.
This module handles the HOW of getting git logs from various sources,
This module handles the HOW of getting git logs from repositories,
but doesn't know anything about parsing them.

Includes transparent caching of git logs to disk for performance.
"""
require Logger
alias GitlockCore.Infrastructure.Workspace
alias GitlockCore.Infrastructure.Workspace.Store

@default_log_options [
"log",
Expand All @@ -16,90 +18,128 @@ defmodule GitlockCore.Infrastructure.GitRepository do
]

@doc """
Fetch raw git log output from various sources.
Sources can be:
- File paths (reads the file)
- Local repository paths (runs git log)
- Remote URLs (uses workspace to clone first)
"""
def fetch_log(source, options \\ %{}) do
case determine_source_type(source) do
:log_file ->
fetch_from_file(source)
Fetch raw git log output from a git repository.

:local_repo ->
fetch_from_local_repo(source, options)
Automatically caches git logs for workspace-managed repositories.
"""
def fetch_log(repo_path, options \\ %{}) do
# Check if this repo is managed by a workspace (and thus cacheable)
workspace =
Store.list()
|> Enum.find(fn ws -> ws[:path] == repo_path end)

case workspace do
%{id: workspace_id} ->
# Get fresh workspace data from store to ensure we have latest cache
workspace_data = Store.get(workspace_id)
cache = workspace_data[:git_log_cache] || %{}
fetch_with_cache(workspace_id, repo_path, options, cache)

_ ->
# No workspace, generate without caching
generate_git_log(repo_path, options)
end
end

:url ->
fetch_from_remote_url(source, options)
# Private Functions

:unknown ->
# Return error that can be transformed by Git adapter
{:error, :enoent}
defp fetch_with_cache(workspace_id, repo_path, options, cache_map) do
options_hash = hash_options(options)
cache_path = Map.get(cache_map, options_hash)

with {:cached, path} when is_binary(path) <- {:cached, cache_path},
{:ok, log} <- File.read(path) do
Logger.info("Using cached git log from #{path}")
{:ok, log}
else
_ ->
# Generate and cache
with {:ok, log} <- generate_git_log(repo_path, options) do
cache_path = build_cache_path(workspace_id, options_hash)

case save_log_to_cache(cache_path, log) do
:ok ->
add_cache_path(workspace_id, options_hash, cache_path)
Logger.info("Successfully cached git log for workspace #{workspace_id}")

{:error, reason} ->
Logger.warning("Failed to cache git log: #{inspect(reason)}")
end

{:ok, log}
end
end
end

@doc """
Determine what type of source we're dealing with.
Returns :log_file, :local_repo, :url, or :unknown
"""
def determine_source_type(source) do
defp generate_git_log(repo_path, options) do
cond do
# Check if it's a remote URL
String.match?(source, ~r/^(https?|git|ssh):\/\//) or String.ends_with?(source, ".git") ->
:url

# Check if it's a git repository
File.dir?(source) && git_repo?(source) ->
:local_repo
not File.exists?(repo_path) ->
{:error, "Git log failed (2): No such file or directory"}

# Check if it's a regular file
File.regular?(source) ->
:log_file
not File.dir?(repo_path) ->
{:error, "Git log failed (20): Not a directory"}

# Check if path suggests it's a log file
String.ends_with?(source, ".txt") or String.ends_with?(source, ".log") ->
:log_file

# Default for non-existent paths - assume they're files for backward compatibility
true ->
:unknown
Logger.debug("Generating git log from repo: #{repo_path}")
cmd_args = build_log_command(options)

case System.cmd("git", cmd_args, cd: repo_path, stderr_to_stdout: true) do
{output, 0} -> {:ok, output}
{error, code} -> {:error, "Git log failed (#{code}): #{error}"}
end
end
end

# Private Functions
defp add_cache_path(workspace_id, options_hash, cache_path) do
workspace = Store.get(workspace_id)
cache = workspace[:git_log_cache] || %{}
updated_cache = Map.put(cache, options_hash, cache_path)

defp fetch_from_file(path) do
Logger.debug("Reading git log from file: #{path}")
File.read(path)
Store.update(workspace_id, %{git_log_cache: updated_cache})
end

defp fetch_from_local_repo(repo_path, options) do
Logger.debug("Generating git log from local repo: #{repo_path}")
cmd_args = build_log_command(options)

case System.cmd("git", cmd_args, cd: repo_path, stderr_to_stdout: true) do
{output, 0} ->
{:ok, output}

{error, code} ->
{:error, "Git log failed (#{code}): #{error}"}
end
defp hash_options(options) do
# Create a deterministic hash of the options
options
|> Enum.sort()
|> :erlang.term_to_binary()
|> then(&:crypto.hash(:sha256, &1))
|> Base.url_encode64(padding: false)
|> String.slice(0..7)
end

defp fetch_from_remote_url(url, options) do
Logger.debug("Fetching git log from remote URL: #{url}")

# Use workspace to handle cloning
Workspace.with(url, options, fn workspace ->
# Once we have the workspace, treat it as a local repo
fetch_from_local_repo(workspace.path, options)
end)
defp build_cache_path(workspace_id, options_hash) do
workspace = Store.get(workspace_id)

case workspace do
%{path: workspace_path} when is_binary(workspace_path) ->
# Store cache inside the workspace directory
Path.join([workspace_path, ".gitlock_cache", "log_#{options_hash}.txt"])

_ ->
# Fallback to temp directory
Path.join([
System.tmp_dir!(),
"gitlock",
"cache",
workspace_id,
"log_#{options_hash}.txt"
])
end
end

defp git_repo?(path) do
git_dir = Path.join(path, ".git")
File.dir?(git_dir) || File.regular?(git_dir)
defp save_log_to_cache(cache_path, log_content) do
cache_dir = Path.dirname(cache_path)

with :ok <- File.mkdir_p(cache_dir),
:ok <- File.write(cache_path, log_content) do
Logger.debug("Cached git log to #{cache_path}")
:ok
else
error ->
Logger.warning("Failed to cache git log: #{inspect(error)}")
error
end
end

defp build_log_command(options) do
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -320,7 +320,8 @@ defmodule GitlockCore.Infrastructure.Workspace.Manager do
type: detect_type(source),
path: path,
created_at: DateTime.utc_now(),
opts: opts
opts: opts,
git_log_cache: %{}
}
end

Expand Down
Loading
Loading