Bhargavi commited on
Commit ·
ad808fd
1
Parent(s): 117e1c9
Add Commit vizualizer
Browse files- __pycache__/git_graph.cpython-311.pyc +0 -0
- app.py +46 -3
- git_graph.py +220 -0
- requirements.txt +2 -0
__pycache__/git_graph.cpython-311.pyc
ADDED
|
Binary file (12.4 kB). View file
|
|
|
app.py
CHANGED
|
@@ -1,9 +1,52 @@
|
|
|
|
|
| 1 |
import gradio as gr
|
|
|
|
|
|
|
| 2 |
|
| 3 |
-
def greet(name):
|
| 4 |
-
return "Hello " + name + "!!"
|
| 5 |
|
| 6 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 7 |
|
| 8 |
if __name__ == "__main__":
|
| 9 |
demo.launch()
|
|
|
|
| 1 |
+
import os
|
| 2 |
import gradio as gr
|
| 3 |
+
from git import InvalidGitRepositoryError
|
| 4 |
+
from git_graph import render_branch_graph
|
| 5 |
|
|
|
|
|
|
|
| 6 |
|
| 7 |
+
def draw_graph(repo_path: str, next_commit_message: str):
|
| 8 |
+
repo_path = repo_path.strip() or os.getcwd()
|
| 9 |
+
try:
|
| 10 |
+
return render_branch_graph(repo_path, next_commit_message)
|
| 11 |
+
except InvalidGitRepositoryError:
|
| 12 |
+
raise gr.Error(f"'{repo_path}' is not a git repository.")
|
| 13 |
+
|
| 14 |
+
|
| 15 |
+
with gr.Blocks(title="Git Branch Visualizer", theme=gr.themes.Base()) as demo:
|
| 16 |
+
gr.Markdown("## Git Branch Graph Visualizer")
|
| 17 |
+
gr.Markdown(
|
| 18 |
+
"Shows all local branches and their commits. "
|
| 19 |
+
"Type a commit message to preview **where the next commit lands** (gold ghost node)."
|
| 20 |
+
)
|
| 21 |
+
|
| 22 |
+
with gr.Row():
|
| 23 |
+
repo_path_input = gr.Textbox(
|
| 24 |
+
label="Repository path",
|
| 25 |
+
value=os.getcwd(),
|
| 26 |
+
placeholder="/path/to/your/repo",
|
| 27 |
+
scale=3,
|
| 28 |
+
)
|
| 29 |
+
next_commit_input = gr.Textbox(
|
| 30 |
+
label="Next commit message (preview)",
|
| 31 |
+
placeholder="feat: add new feature",
|
| 32 |
+
scale=2,
|
| 33 |
+
)
|
| 34 |
+
|
| 35 |
+
render_button = gr.Button("Render Graph", variant="primary")
|
| 36 |
+
|
| 37 |
+
graph_output = gr.Plot(label="Branch Graph")
|
| 38 |
+
info_output = gr.Markdown()
|
| 39 |
+
|
| 40 |
+
render_button.click(
|
| 41 |
+
fn=draw_graph,
|
| 42 |
+
inputs=[repo_path_input, next_commit_input],
|
| 43 |
+
outputs=[graph_output, info_output],
|
| 44 |
+
)
|
| 45 |
+
demo.load(
|
| 46 |
+
fn=draw_graph,
|
| 47 |
+
inputs=[repo_path_input, next_commit_input],
|
| 48 |
+
outputs=[graph_output, info_output],
|
| 49 |
+
)
|
| 50 |
|
| 51 |
if __name__ == "__main__":
|
| 52 |
demo.launch()
|
git_graph.py
ADDED
|
@@ -0,0 +1,220 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
import matplotlib
|
| 2 |
+
matplotlib.use("Agg")
|
| 3 |
+
import matplotlib.pyplot as plt
|
| 4 |
+
from git import Repo, InvalidGitRepositoryError
|
| 5 |
+
|
| 6 |
+
BRANCH_COLORS = {
|
| 7 |
+
"main": "#4C8BF5",
|
| 8 |
+
"master": "#4C8BF5",
|
| 9 |
+
}
|
| 10 |
+
FALLBACK_COLORS = ["#2ECC71", "#E84B3A", "#F39C12", "#9B59B6", "#1ABC9C", "#E67E22"]
|
| 11 |
+
GHOST_COLOR = "#FFD700"
|
| 12 |
+
HEAD_RING_COLOR = "#FFFFFF"
|
| 13 |
+
BG_COLOR = "#0D1117"
|
| 14 |
+
TEXT_COLOR = "#C9D1D9"
|
| 15 |
+
X_SPACING = 1.5
|
| 16 |
+
Y_SPACING = -0.7
|
| 17 |
+
|
| 18 |
+
|
| 19 |
+
def get_branch_color(branch_name: str, index: int) -> str:
|
| 20 |
+
return BRANCH_COLORS.get(branch_name, FALLBACK_COLORS[index % len(FALLBACK_COLORS)])
|
| 21 |
+
|
| 22 |
+
|
| 23 |
+
def load_repo(repo_path: str) -> Repo:
|
| 24 |
+
return Repo(repo_path)
|
| 25 |
+
|
| 26 |
+
|
| 27 |
+
def get_commit_graph(repo: Repo) -> list[dict]:
|
| 28 |
+
seen = set()
|
| 29 |
+
commits = []
|
| 30 |
+
for commit in repo.iter_commits("--all"):
|
| 31 |
+
if commit.hexsha in seen:
|
| 32 |
+
continue
|
| 33 |
+
seen.add(commit.hexsha)
|
| 34 |
+
commits.append({
|
| 35 |
+
"sha": commit.hexsha,
|
| 36 |
+
"short_sha": commit.hexsha[:7],
|
| 37 |
+
"message": commit.message.strip().splitlines()[0],
|
| 38 |
+
"parents": [p.hexsha for p in commit.parents],
|
| 39 |
+
})
|
| 40 |
+
return commits
|
| 41 |
+
|
| 42 |
+
|
| 43 |
+
def tag_commits_with_branches(repo: Repo, commits: list[dict]) -> list[dict]:
|
| 44 |
+
sha_to_labels: dict[str, list[str]] = {}
|
| 45 |
+
for branch in repo.branches:
|
| 46 |
+
tip = branch.commit.hexsha
|
| 47 |
+
sha_to_labels.setdefault(tip, []).append(branch.name)
|
| 48 |
+
|
| 49 |
+
head_sha = repo.head.commit.hexsha
|
| 50 |
+
for commit in commits:
|
| 51 |
+
commit["labels"] = sha_to_labels.get(commit["sha"], [])
|
| 52 |
+
commit["is_head"] = commit["sha"] == head_sha
|
| 53 |
+
return commits
|
| 54 |
+
|
| 55 |
+
|
| 56 |
+
def assign_lanes(commits: list[dict]) -> dict[str, int]:
|
| 57 |
+
lane_by_sha: dict[str, int] = {}
|
| 58 |
+
active_lanes: list[str | None] = []
|
| 59 |
+
|
| 60 |
+
for commit in commits:
|
| 61 |
+
sha = commit["sha"]
|
| 62 |
+
parents = commit["parents"]
|
| 63 |
+
|
| 64 |
+
inherited_lane = None
|
| 65 |
+
for parent_sha in parents:
|
| 66 |
+
if parent_sha in lane_by_sha:
|
| 67 |
+
candidate = lane_by_sha[parent_sha]
|
| 68 |
+
if active_lanes[candidate] == parent_sha:
|
| 69 |
+
inherited_lane = candidate
|
| 70 |
+
break
|
| 71 |
+
|
| 72 |
+
if inherited_lane is not None:
|
| 73 |
+
lane = inherited_lane
|
| 74 |
+
else:
|
| 75 |
+
try:
|
| 76 |
+
lane = active_lanes.index(None)
|
| 77 |
+
except ValueError:
|
| 78 |
+
lane = len(active_lanes)
|
| 79 |
+
active_lanes.append(None)
|
| 80 |
+
|
| 81 |
+
lane_by_sha[sha] = lane
|
| 82 |
+
if lane < len(active_lanes):
|
| 83 |
+
active_lanes[lane] = sha
|
| 84 |
+
else:
|
| 85 |
+
active_lanes.append(sha)
|
| 86 |
+
|
| 87 |
+
return lane_by_sha
|
| 88 |
+
|
| 89 |
+
|
| 90 |
+
def inject_ghost_node(commits: list[dict], head_sha: str, current_branch: str, message: str) -> list[dict]:
|
| 91 |
+
ghost = {
|
| 92 |
+
"sha": "GHOST_NEXT",
|
| 93 |
+
"short_sha": "next",
|
| 94 |
+
"message": message.strip(),
|
| 95 |
+
"parents": [head_sha],
|
| 96 |
+
"labels": [f"← {current_branch} (next)"],
|
| 97 |
+
"is_head": False,
|
| 98 |
+
}
|
| 99 |
+
return [ghost] + commits
|
| 100 |
+
|
| 101 |
+
|
| 102 |
+
def build_figure(repo: Repo, commits: list[dict], current_branch: str, next_commit_message: str) -> plt.Figure:
|
| 103 |
+
head_sha = repo.head.commit.hexsha
|
| 104 |
+
ghost_sha = "GHOST_NEXT"
|
| 105 |
+
|
| 106 |
+
if next_commit_message.strip():
|
| 107 |
+
commits = inject_ghost_node(commits, head_sha, current_branch, next_commit_message)
|
| 108 |
+
|
| 109 |
+
lane_by_sha = assign_lanes(commits)
|
| 110 |
+
sha_to_index = {c["sha"]: i for i, c in enumerate(commits)}
|
| 111 |
+
|
| 112 |
+
num_lanes = max(lane_by_sha.values(), default=0) + 1
|
| 113 |
+
fig_width = max(12, 4 + num_lanes * 2)
|
| 114 |
+
fig_height = max(6, len(commits) * 0.6 + 2)
|
| 115 |
+
|
| 116 |
+
fig, ax = plt.subplots(figsize=(fig_width, fig_height))
|
| 117 |
+
fig.patch.set_facecolor(BG_COLOR)
|
| 118 |
+
ax.set_facecolor(BG_COLOR)
|
| 119 |
+
|
| 120 |
+
def node_position(sha: str) -> tuple[float, float]:
|
| 121 |
+
idx = sha_to_index[sha]
|
| 122 |
+
lane = lane_by_sha[sha]
|
| 123 |
+
return lane * X_SPACING, idx * Y_SPACING
|
| 124 |
+
|
| 125 |
+
for commit in commits:
|
| 126 |
+
x_child, y_child = node_position(commit["sha"])
|
| 127 |
+
lane = lane_by_sha[commit["sha"]]
|
| 128 |
+
edge_color = get_branch_color("", lane)
|
| 129 |
+
|
| 130 |
+
for parent_sha in commit["parents"]:
|
| 131 |
+
if parent_sha not in sha_to_index:
|
| 132 |
+
continue
|
| 133 |
+
x_parent, y_parent = node_position(parent_sha)
|
| 134 |
+
is_merge = x_child != x_parent
|
| 135 |
+
ax.annotate(
|
| 136 |
+
"",
|
| 137 |
+
xy=(x_parent, y_parent),
|
| 138 |
+
xytext=(x_child, y_child),
|
| 139 |
+
arrowprops=dict(
|
| 140 |
+
arrowstyle="-|>",
|
| 141 |
+
color=edge_color,
|
| 142 |
+
lw=1.8,
|
| 143 |
+
connectionstyle=f"arc3,rad={'0.3' if is_merge else '0.0'}",
|
| 144 |
+
),
|
| 145 |
+
zorder=2,
|
| 146 |
+
)
|
| 147 |
+
|
| 148 |
+
for commit in commits:
|
| 149 |
+
x, y = node_position(commit["sha"])
|
| 150 |
+
lane = lane_by_sha[commit["sha"]]
|
| 151 |
+
node_color = get_branch_color("", lane)
|
| 152 |
+
is_ghost = commit["sha"] == ghost_sha
|
| 153 |
+
|
| 154 |
+
if is_ghost:
|
| 155 |
+
ax.plot(x, y, "o", markersize=13, color=GHOST_COLOR, alpha=0.35, zorder=4)
|
| 156 |
+
ax.plot(x, y, "o", markersize=13, color=GHOST_COLOR, fillstyle="none",
|
| 157 |
+
markeredgewidth=2, linestyle="--", zorder=5)
|
| 158 |
+
elif commit["is_head"]:
|
| 159 |
+
ax.plot(x, y, "o", markersize=15, color=node_color, zorder=4)
|
| 160 |
+
ax.plot(x, y, "o", markersize=8, color=HEAD_RING_COLOR, zorder=5)
|
| 161 |
+
else:
|
| 162 |
+
ax.plot(x, y, "o", markersize=10, color=node_color, zorder=4)
|
| 163 |
+
|
| 164 |
+
label_text = f"{commit['short_sha']} {commit['message'][:50]}"
|
| 165 |
+
ax.text(x + 0.35, y, label_text, va="center", ha="left",
|
| 166 |
+
fontsize=8, color=TEXT_COLOR, fontfamily="monospace", zorder=6)
|
| 167 |
+
|
| 168 |
+
badge_offset_x = x + 0.35
|
| 169 |
+
badge_offset_y = y + 0.22
|
| 170 |
+
for label in commit["labels"]:
|
| 171 |
+
is_current = label == current_branch
|
| 172 |
+
badge_bg = "#9E6A03" if is_current else "#238636"
|
| 173 |
+
ax.text(
|
| 174 |
+
badge_offset_x, badge_offset_y,
|
| 175 |
+
f" {label} ",
|
| 176 |
+
va="center", ha="left",
|
| 177 |
+
fontsize=7, color="white", fontfamily="monospace",
|
| 178 |
+
bbox=dict(boxstyle="round,pad=0.25", facecolor=badge_bg, edgecolor="none"),
|
| 179 |
+
zorder=7,
|
| 180 |
+
)
|
| 181 |
+
badge_offset_x += len(label) * 0.075 + 0.6
|
| 182 |
+
|
| 183 |
+
ax.set_xlim(-0.8, num_lanes * X_SPACING + 6)
|
| 184 |
+
ax.set_ylim(len(commits) * Y_SPACING - 0.8, 0.8)
|
| 185 |
+
ax.set_title("Git Branch Graph", color=TEXT_COLOR, fontsize=12, pad=12)
|
| 186 |
+
ax.axis("off")
|
| 187 |
+
plt.tight_layout()
|
| 188 |
+
|
| 189 |
+
return fig
|
| 190 |
+
|
| 191 |
+
|
| 192 |
+
def build_info(repo: Repo, current_branch: str, next_commit_message: str) -> str:
|
| 193 |
+
head_sha = repo.head.commit.hexsha
|
| 194 |
+
staged_files = [item.a_path for item in repo.index.diff("HEAD")]
|
| 195 |
+
untracked = repo.untracked_files
|
| 196 |
+
|
| 197 |
+
info_lines = [
|
| 198 |
+
f"**Current branch:** `{current_branch}`",
|
| 199 |
+
f"**HEAD:** `{head_sha[:7]}`",
|
| 200 |
+
]
|
| 201 |
+
if staged_files:
|
| 202 |
+
info_lines.append("**Staged (ready to commit):** " + ", ".join(f"`{f}`" for f in staged_files))
|
| 203 |
+
if untracked:
|
| 204 |
+
info_lines.append("**Untracked:** " + ", ".join(f"`{f}`" for f in untracked[:5]))
|
| 205 |
+
if next_commit_message.strip():
|
| 206 |
+
info_lines.append(
|
| 207 |
+
f"**Next commit preview:** `{next_commit_message.strip()}` ← will land on `{current_branch}`"
|
| 208 |
+
)
|
| 209 |
+
|
| 210 |
+
return "\n\n".join(info_lines)
|
| 211 |
+
|
| 212 |
+
|
| 213 |
+
def render_branch_graph(repo_path: str, next_commit_message: str) -> tuple[plt.Figure, str]:
|
| 214 |
+
repo = load_repo(repo_path)
|
| 215 |
+
commits = get_commit_graph(repo)
|
| 216 |
+
commits = tag_commits_with_branches(repo, commits)
|
| 217 |
+
current_branch = repo.active_branch.name if not repo.head.is_detached else "HEAD (detached)"
|
| 218 |
+
fig = build_figure(repo, commits, current_branch, next_commit_message)
|
| 219 |
+
info = build_info(repo, current_branch, next_commit_message)
|
| 220 |
+
return fig, info
|
requirements.txt
CHANGED
|
@@ -1 +1,3 @@
|
|
| 1 |
gradio
|
|
|
|
|
|
|
|
|
| 1 |
gradio
|
| 2 |
+
gitpython
|
| 3 |
+
matplotlib
|