diff --git a/README.md b/README.md index 105a826..7e0fd02 100644 --- a/README.md +++ b/README.md @@ -1,26 +1,44 @@ -# Git-Analytics -[![PyPI](https://img.shields.io/pypi/v/git-analytics.svg?color=green)](https://pypi.org/project/git-analytics/) -[![Python Versions](https://img.shields.io/pypi/pyversions/git-analytics.svg)](https://pypi.org/project/git-analytics/) -[![code quality check](https://github.com/n0rfas/git-analytics/actions/workflows/code-check-dev.yml/badge.svg?branch=dev)](https://github.com/n0rfas/git-analytics/tree/dev) -[![python versions check](https://github.com/n0rfas/git-analytics/actions/workflows/python-matrix-main.yml/badge.svg?branch=main)](https://github.com/n0rfas/git-analytics/tree/main) - -The detailed analysis tool for git repositories. +The detailed analysis tool for git repositories - forked version from [here](https://github.com/n0rfas/git-analytics) with SQLite acting as the data store to provide better experience. ## Installation The latest stable version can be installed directly from PyPI: ```sh -pip install git-analytics +pip install git-analytics-sqlite ``` ## Usage -To run, enter the command and open the browser at [http://localhost:8000/](http://localhost:8000/). +Run from inside any git repository, then open [http://localhost:8000/](http://localhost:8000/) in your browser: + +```sh +git-analytics-sqlite +``` + +On first launch the dashboard will show a **Scan repository** button. Click it to parse the git history and populate the local database. Subsequent launches load instantly from the cached data. Use the **Scan** button in the header at any time to pick up new commits. + +### SQLite database + +Commit data is stored in a per-repository SQLite database under your platform's user data directory: + +| Platform | Location | +|----------|----------| +| Linux | `~/.local/share/git-analytics//_.db` | +| macOS | `~/Library/Application Support/git-analytics//_.db` | +| Windows | `%APPDATA%\git-analytics\\_.db` | + +The `` is a short fingerprint of the repository's full path, so two repositories with the same folder name never share a database. + +Use the folder icon button in the header to open the database directory in your file manager. + +### Custom database path + +Pass `--db` to override the default location: ```sh -git-analytics +git-analytics-sqlite --db /path/to/my.db ``` ## Screenshots @@ -38,7 +56,20 @@ poetry install --with dev ### Running ```bash -poetry run git-analytics +poetry run git-analytics-sqlite +``` + +### Building + +```bash +poetry build +# produces dist/git_analytics--py3-none-any.whl +``` + +Install the built wheel directly with pip: + +```bash +pip install dist/git_analytics-*.whl ``` ### Tests diff --git a/git_analytics/main.py b/git_analytics/main.py index 94b4ab0..d7bd577 100644 --- a/git_analytics/main.py +++ b/git_analytics/main.py @@ -1,8 +1,8 @@ +import argparse import os +from pathlib import Path from wsgiref.simple_server import make_server -from git import InvalidGitRepositoryError, Repo - from git_analytics.analyzers import ( AuthorsStatisticsAnalyzer, CommitsSummaryAnalyzer, @@ -12,7 +12,7 @@ LinesAnalyzer, ) from git_analytics.engine import CommitAnalyticsEngine, FileAnalyticsEngine -from git_analytics.sources import GitCommitSource +from git_analytics.sources import SqliteCommitSource, repo_db_path from git_analytics.web_app import create_web_app @@ -28,23 +28,27 @@ def make_analyzers(): def run(): - try: - path_repo = os.getenv("PATH_REPO", ".") - repo = Repo(path_repo) - name_branch = repo.active_branch.name - except InvalidGitRepositoryError: - print("Error: Current directory is not a git repository.") - return + parser = argparse.ArgumentParser(prog="git-analytics") + parser.add_argument( + "--db", + metavar="PATH", + help="path to SQLite database (default: user data directory)", + ) + args = parser.parse_args() - extension_stats = FileAnalyticsEngine().run() + path_repo = os.getenv("PATH_REPO", ".") + db_path = Path(args.db) if args.db else repo_db_path(path_repo) + + repo = _try_get_repo(path_repo) + additional_data = _build_additional_data(repo) engine = CommitAnalyticsEngine( - source=GitCommitSource(repo), + source=SqliteCommitSource(db_path), analyzers_factory=make_analyzers, - additional_data={"name_branch": name_branch, "extension_stats": extension_stats}, + additional_data=additional_data, ) - web_app = create_web_app(engine=engine) + web_app = create_web_app(engine=engine, db_path=db_path, repo=repo) with make_server("", 8000, web_app) as httpd: print("Web service started at http://localhost:8000/") @@ -58,5 +62,27 @@ def run(): print("Web service stopped") +def _try_get_repo(path: str): + try: + from git import InvalidGitRepositoryError, Repo + return Repo(path) + except Exception: + return None + + +def _build_additional_data(repo): + data = {} + if repo is not None: + try: + data["name_branch"] = repo.active_branch.name + except Exception: + pass + try: + data["extension_stats"] = FileAnalyticsEngine().run() + except Exception: + pass + return data + + if __name__ == "__main__": run() diff --git a/git_analytics/sources/__init__.py b/git_analytics/sources/__init__.py index 0aa904a..74a866d 100644 --- a/git_analytics/sources/__init__.py +++ b/git_analytics/sources/__init__.py @@ -1,7 +1,11 @@ from .git_commit_adapter import GitCommitSource from .git_log_adapter import GitLogSource +from .sqlite_commit_source import SqliteCommitSource, populate_sqlite, repo_db_path __all__ = [ "GitCommitSource", "GitLogSource", + "SqliteCommitSource", + "populate_sqlite", + "repo_db_path", ] diff --git a/git_analytics/sources/sqlite_commit_source.py b/git_analytics/sources/sqlite_commit_source.py new file mode 100644 index 0000000..ce8b1d7 --- /dev/null +++ b/git_analytics/sources/sqlite_commit_source.py @@ -0,0 +1,115 @@ +import hashlib +import os +import sqlite3 +import sys +from datetime import datetime +from pathlib import Path +from typing import Iterator + +from git_analytics.entities import AnalyticsCommit, FileChangeStats +from git_analytics.interfaces import CommitSource + + +def user_data_dir() -> Path: + if sys.platform == "win32": + base = Path(os.environ.get("APPDATA", Path.home())) + elif sys.platform == "darwin": + base = Path.home() / "Library" / "Application Support" + else: + base = Path(os.environ.get("XDG_DATA_HOME", Path.home() / ".local" / "share")) + return base / "git-analytics" + + +def repo_db_path(repo_path: str = ".") -> Path: + full_path = str(Path(repo_path).resolve()) + name = Path(full_path).name + path_hash = hashlib.sha1(full_path.encode()).hexdigest()[:8] + return user_data_dir() / name / f"{name}_{path_hash}.db" + + +_CREATE_COMMITS = """ +CREATE TABLE IF NOT EXISTS commits ( + sha TEXT PRIMARY KEY, + commit_author TEXT NOT NULL, + committed_datetime TEXT NOT NULL, + lines_insertions INTEGER NOT NULL, + lines_deletions INTEGER NOT NULL, + files_changed INTEGER NOT NULL, + message TEXT NOT NULL +) +""" + +_CREATE_COMMIT_FILES = """ +CREATE TABLE IF NOT EXISTS commit_files ( + sha TEXT NOT NULL REFERENCES commits(sha), + filepath TEXT NOT NULL, + insertions INTEGER NOT NULL, + deletions INTEGER NOT NULL +) +""" + + +def populate_sqlite(source: CommitSource, db_path: Path) -> None: + db_path.parent.mkdir(parents=True, exist_ok=True) + conn = sqlite3.connect(db_path) + try: + conn.execute(_CREATE_COMMITS) + conn.execute(_CREATE_COMMIT_FILES) + with conn: + for commit in source.iter_commits(): + conn.execute( + "INSERT OR REPLACE INTO commits VALUES (?,?,?,?,?,?,?)", + ( + commit.sha, + commit.commit_author, + commit.committed_datetime.isoformat(), + commit.lines_insertions, + commit.lines_deletions, + commit.files_changed, + commit.message, + ), + ) + for filepath, stats in commit.files.items(): + conn.execute( + "INSERT INTO commit_files VALUES (?,?,?,?)", + (commit.sha, filepath, stats.insertions, stats.deletions), + ) + finally: + conn.close() + + +class SqliteCommitSource(CommitSource): + def __init__(self, db_path: Path) -> None: + self._db_path = db_path + + def iter_commits(self) -> Iterator[AnalyticsCommit]: + conn = sqlite3.connect(self._db_path) + try: + rows = conn.execute( + "SELECT sha, commit_author, committed_datetime, lines_insertions, " + "lines_deletions, files_changed, message " + "FROM commits ORDER BY committed_datetime DESC" + ).fetchall() + + for row in rows: + sha, author, dt_str, insertions, deletions, files_changed, message = row + files_rows = conn.execute( + "SELECT filepath, insertions, deletions FROM commit_files WHERE sha=?", + (sha,), + ).fetchall() + files = { + filepath: FileChangeStats(insertions=ins, deletions=dels) + for filepath, ins, dels in files_rows + } + yield AnalyticsCommit( + sha=sha, + commit_author=author, + committed_datetime=datetime.fromisoformat(dt_str), + lines_insertions=insertions, + lines_deletions=deletions, + files_changed=files_changed, + message=message, + files=files, + ) + finally: + conn.close() diff --git a/git_analytics/static/index.html b/git_analytics/static/index.html index 1cf9fa3..9dcc57e 100644 --- a/git_analytics/static/index.html +++ b/git_analytics/static/index.html @@ -27,13 +27,21 @@ Logo

Git-Analytics

-
+
Site GitHub + +