Skip to content
Open
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
53 changes: 42 additions & 11 deletions README.md
Original file line number Diff line number Diff line change
@@ -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/<repo>/<repo>_<hash>.db` |
| macOS | `~/Library/Application Support/git-analytics/<repo>/<repo>_<hash>.db` |
| Windows | `%APPDATA%\git-analytics\<repo>\<repo>_<hash>.db` |

The `<hash>` 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
Expand All @@ -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-<version>-py3-none-any.whl
```

Install the built wheel directly with pip:

```bash
pip install dist/git_analytics-*.whl
```

### Tests
Expand Down
54 changes: 40 additions & 14 deletions git_analytics/main.py
Original file line number Diff line number Diff line change
@@ -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,
Expand All @@ -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


Expand All @@ -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/")
Expand All @@ -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()
4 changes: 4 additions & 0 deletions git_analytics/sources/__init__.py
Original file line number Diff line number Diff line change
@@ -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",
]
115 changes: 115 additions & 0 deletions git_analytics/sources/sqlite_commit_source.py
Original file line number Diff line number Diff line change
@@ -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()
35 changes: 34 additions & 1 deletion git_analytics/static/index.html
Original file line number Diff line number Diff line change
Expand Up @@ -27,13 +27,21 @@
<img src="favicon.svg" alt="Logo" width="32" height="32" class="me-2">
<h1 class="h4 mb-0">Git-Analytics</h1>
</div>
<div>
<div class="d-flex align-items-center gap-2">
<a href="https://git-analytics.com" target="_blank" class="btn btn-outline-light">
<i class="bi bi-link-45deg"></i> Site
</a>
<a href="https://github.com/n0rfas/git-analytics" target="_blank" class="btn btn-outline-light">
<i class="bi bi-github"></i> GitHub
</a>
<button id="scanBtn" class="btn btn-success d-none" onclick="scanRepository()">
<i class="bi bi-arrow-repeat" id="scanIcon"></i> Scan
</button>
<button id="openFolderBtn" class="btn btn-outline-light d-none"
title="" onclick="openDbFolder()" style="font-size:0.8rem;">
<i class="bi bi-folder2-open"></i>
<span id="dbFolderLabel" class="ms-1"></span>
</button>
</div>
<div class="d-flex align-items-center gap-2">
<div class="dropdown">
Expand Down Expand Up @@ -68,6 +76,19 @@ <h1 class="h4 mb-0">Git-Analytics</h1>
<!-- content -->
<main class="flex-grow-1 p-3">
<div class="container">
<!-- first-run banner, shown when DB has no data -->
<div id="noDataBanner" class="d-none mb-4">
<div class="card border-warning">
<div class="card-body text-center py-5">
<i class="bi bi-database-x display-4 text-warning mb-3 d-block"></i>
<h4>No data yet</h4>
<p class="text-muted mb-4">Scan your repository to populate the database and view analytics.</p>
<button class="btn btn-success btn-lg" onclick="scanRepository()">
<i class="bi bi-arrow-repeat"></i> Scan repository
</button>
</div>
</div>
</div>
<section class="view" data-view="overview">
<div class="row">
<div class="col-md-12 mb-3">
Expand Down Expand Up @@ -382,5 +403,17 @@ <h2>Commits</h2>
</div>
</div>

<!-- Scanning Modal -->
<div class="modal" id="scanningModal" tabindex="-1" aria-hidden="true" data-bs-backdrop="static" data-bs-keyboard="false">
<div class="modal-dialog modal-dialog-centered">
<div class="modal-content text-center">
<div class="modal-body">
<div class="spinner-border text-success mb-3" role="status"></div>
<p class="mb-0"><strong>Scanning repository. This may take a moment...</strong></p>
</div>
</div>
</div>
</div>

</body>
</html>
Loading