Skip to content
Open
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
234 changes: 186 additions & 48 deletions git-context
Original file line number Diff line number Diff line change
Expand Up @@ -4,10 +4,11 @@ git-context — Generate AI-friendly context for any git repo.
Dump project structure, git log, file contents, and branch topology
in one optimized prompt-ready block.

Usage: git context [--depth N] [--files] [--log N] [--output file] [--dir <path>]
Usage: git context [--depth N] [--files] [--log N] [--output file] [--dir <path>] [--json]
"""

import argparse
import json
import os
import subprocess
import sys
Expand Down Expand Up @@ -67,6 +68,26 @@ def tree(path, prefix="", ignored=DEFAULT_IGNORE, depth=3, current_depth=0):
result += tree(fp, prefix + deeper, ignored, depth, current_depth + 1)
return result

def tree_json(path, ignored=DEFAULT_IGNORE, depth=3, current_depth=0):
"""Return directory structure as a list of dicts."""
if current_depth > depth:
return []
items = []
try:
entries = sorted(os.listdir(path))
except PermissionError:
return []
for e in entries:
fp = os.path.join(path, e)
if e.startswith('.') or should_ignore(e, ignored):
continue
if os.path.isdir(fp):
children = tree_json(fp, ignored, depth, current_depth + 1)
items.append({"name": e, "type": "dir", "children": children})
else:
items.append({"name": e, "type": "file", "size": os.path.getsize(fp)})
return items

def file_contents(path, ignored=DEFAULT_IGNORE, max_total=15000):
ext_map = {
'.py': 'py', '.js': 'js', '.ts': 'ts', '.tsx': 'tsx', '.jsx': 'jsx',
Expand All @@ -83,7 +104,7 @@ def file_contents(path, ignored=DEFAULT_IGNORE, max_total=15000):
snippet_exts = {'.py', '.js', '.ts', '.tsx', '.jsx', '.go', '.rs', '.rb',
'.java', '.kt', '.swift', '.c', '.h', '.cpp', '.cs', '.php',
'.vue', '.svelte', '.css', '.scss', '.html', '.xml',
'.json', '.yaml', '.yml', '.toml', '.md', '.sh', '.bash',
'.json', '.yaml', '.yml', '.md', '.sh', '.bash',
'.zsh', '.sql', '.graphql', '.proto', '.tf', '.conf', '.ini'}

result = ""
Expand Down Expand Up @@ -114,20 +135,98 @@ def file_contents(path, ignored=DEFAULT_IGNORE, max_total=15000):
break
return result

def file_contents_json(path, ignored=DEFAULT_IGNORE, max_total=15000):
"""Return file contents as list of {path, language, content} dicts."""
ext_map = {
'.py': 'py', '.js': 'js', '.ts': 'ts', '.tsx': 'tsx', '.jsx': 'jsx',
'.go': 'go', '.rs': 'rs', '.rb': 'rb', '.java': 'java', '.kt': 'kt',
'.swift': 'swift', '.c': 'c', '.h': 'h', '.cpp': 'cpp', '.hpp': 'hpp',
'.cs': 'cs', '.php': 'php', '.vue': 'vue', '.svelte': 'svelte',
'.css': 'css', '.scss': 'scss', '.html': 'html', '.xml': 'xml',
'.json': 'json', '.yaml': 'yaml', '.yml': 'yaml', '.toml': 'toml',
'.md': 'md', '.txt': 'txt', '.sh': 'sh', '.bash': 'sh', '.zsh': 'sh',
'.sql': 'sql', '.graphql': 'graphql', '.proto': 'proto',
'.dockerfile': 'dockerfile', '.tf': 'tf', '.env': 'env',
'.conf': 'conf', '.ini': 'ini', '.cfg': 'cfg',
}
snippet_exts = set(ext_map.keys()).intersection({
'.py', '.js', '.ts', '.tsx', '.jsx', '.go', '.rs', '.rb',
'.java', '.kt', '.swift', '.c', '.h', '.cpp', '.cs', '.php',
'.vue', '.svelte', '.css', '.scss', '.html', '.xml',
'.json', '.yaml', '.yml', '.md', '.sh', '.bash',
'.zsh', '.sql', '.graphql', '.proto', '.tf', '.conf', '.ini',
})

files_list = []
total = 0
for root, dirs, files in os.walk(path):
dirs[:] = [d for d in dirs if not d.startswith('.') and d not in ignored and d != 'node_modules']
for f in sorted(files):
ext = os.path.splitext(f)[1].lower()
if f.endswith('.min.js') or f.endswith('.min.css'):
continue
if ext not in snippet_exts:
continue
fp = os.path.join(root, f)
try:
content = Path(fp).read_text(encoding='utf-8', errors='replace')
rel = os.path.relpath(fp, path)
if total + len(content) > max_total:
remaining = max_total - total
files_list.append({
"path": rel,
"language": ext_map.get(ext, ""),
"content": content[:remaining],
"truncated": True,
})
break
files_list.append({
"path": rel,
"language": ext_map.get(ext, ""),
"content": content,
})
total += len(content)
except Exception:
continue
if total >= max_total:
break
return files_list

def fmt_timestamp(ts):
try:
dt = datetime.fromisoformat(ts)
return dt.strftime('%Y-%m-%d %H:%M')
except:
return ts[:19]

def collect_commits(target, count=20):
"""Parse git log into structured list."""
raw = run(["git", "log", f"--max-count={count}",
"--pretty=format:%H||%h||%s||%an||%ar||%ai"], target)
commits = []
for line in raw.split("\n"):
if not line.strip():
continue
parts = line.split("||", 5)
if len(parts) == 6:
commits.append({
"hash": parts[0],
"short_hash": parts[1],
"message": parts[2],
"author": parts[3],
"relative_time": parts[4],
"date": parts[5],
})
return commits

def main():
p = argparse.ArgumentParser(description='Generate AI-friendly context for a git repo')
p.add_argument('--depth', type=int, default=4, help='Directory tree depth (default: 4)')
p.add_argument('--files', action='store_true', help='Include source file contents')
p.add_argument('--log', type=int, default=20, help='Number of recent commits (default: 20, 0=skip)')
p.add_argument('--output', '-o', help='Write to file instead of stdout')
p.add_argument('--dir', default=os.getcwd(), help='Target directory (default: cwd)')
p.add_argument('--json', action='store_true', help='Output as JSON')
args = p.parse_args()

target = os.path.abspath(args.dir)
Expand All @@ -136,60 +235,99 @@ def main():
sys.exit(1)

repo_name = os.path.basename(target)
sections = []
sections.append(f"# git-context: {repo_name}")
sections.append(f"Generated: {datetime.now().strftime('%Y-%m-%d %H:%M:%S')}")
sections.append(f"Path: {target}")
sections.append("")
ts = datetime.now().strftime('%Y-%m-%d %H:%M:%S')

# Git info
# ── git info ──────────────────────────────────────────────────────────
branch = run(["git", "rev-parse", "--abbrev-ref", "HEAD"], target)
remote = run(["git", "remote", "get-url", "origin"], target)
sections.append(f"## Git Info\n- Branch: `{branch}`")
sections.append(f"- Remote: {remote}")

has_unstaged = run(["git", "diff", "--stat"], target)
has_staged = run(["git", "diff", "--cached", "--stat"], target)
status = ""
if has_unstaged: status += f"\n- Unstaged changes: {has_unstaged.split(chr(10))[-1]}"
if has_staged: status += f"\n- Staged changes: {has_staged.split(chr(10))[-1]}"
if not has_unstaged and not has_staged:
status += "\n- Working tree: clean"
sections.append(status)

# Recent commits
if args.log > 0:
log = run(["git", "log", f"--max-count={args.log}", "--oneline", "--graph",
"--pretty=format:%h %d %s (%an, %ar)"], target)
if log:
sections.append(f"\n## Recent Commits (last {args.log})")
sections.append(f"```\n{log}\n```")

# Branch topology
branches = run(["git", "branch", "-a"], target)
if branches:
sections.append("\n## Branches")
sections.append(f"```\n{branches}\n```")

# Directory tree
tree_out = tree(target, ignored=DEFAULT_IGNORE, depth=args.depth)
sections.append(f"\n## Project Structure (depth={args.depth})")
sections.append(f"```\n{tree_out}\n```")

# File contents
if args.files:
contents = file_contents(target)
if contents:
sections.append("\n## File Contents")
sections.append(contents)

output = "\n".join(sections)


if args.json:
payload = {
"repo": repo_name,
"generated": ts,
"path": target,
"git_info": {
"branch": branch,
"remote": remote,
"status": {
"has_unstaged": bool(has_unstaged),
"has_staged": bool(has_staged),
"working_tree": "clean" if not has_unstaged and not has_staged else "dirty",
},
},
}

# recent commits
if args.log > 0:
payload["recent_commits"] = collect_commits(target, args.log)

# branches
raw_branches = run(["git", "branch", "-a"], target)
if raw_branches:
payload["branches"] = [b.strip() for b in raw_branches.split("\n") if b.strip()]

# project structure
payload["project_structure"] = {
"depth": args.depth,
"tree": tree_json(target, DEFAULT_IGNORE, args.depth),
}

# file contents
if args.files:
payload["file_contents"] = file_contents_json(target, DEFAULT_IGNORE)

raw_output = json.dumps(payload, indent=2)

else:
# ── text output (original) ────────────────────────────────────────
sections = []
sections.append(f"# git-context: {repo_name}")
sections.append(f"Generated: {ts}")
sections.append(f"Path: {target}")
sections.append("")
sections.append(f"## Git Info\n- Branch: `{branch}`")
sections.append(f"- Remote: {remote}")

status = ""
if has_unstaged:
status += f"\n- Unstaged changes: {has_unstaged.split(chr(10))[-1]}"
if has_staged:
status += f"\n- Staged changes: {has_staged.split(chr(10))[-1]}"
if not has_unstaged and not has_staged:
status += "\n- Working tree: clean"
sections.append(status)

if args.log > 0:
log = run(["git", "log", f"--max-count={args.log}", "--oneline", "--graph",
"--pretty=format:%h %d %s (%an, %ar)"], target)
if log:
sections.append(f"\n## Recent Commits (last {args.log})")
sections.append(f"```\n{log}\n```")

branches = run(["git", "branch", "-a"], target)
if branches:
sections.append("\n## Branches")
sections.append(f"```\n{branches}\n```")

tree_out = tree(target, ignored=DEFAULT_IGNORE, depth=args.depth)
sections.append(f"\n## Project Structure (depth={args.depth})")
sections.append(f"```\n{tree_out}\n```")

if args.files:
contents = file_contents(target)
if contents:
sections.append("\n## File Contents")
sections.append(contents)

raw_output = "\n".join(sections)

if args.output:
Path(args.output).write_text(output)
Path(args.output).write_text(raw_output)
print(f"✅ Written to {args.output}")
else:
print(output)
print(raw_output)

if __name__ == '__main__':
main()