repofuse 0.1.0__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
repofuse/__init__.py ADDED
@@ -0,0 +1,3 @@
1
+ """repofuse — Monorepo management: multi-repo isolation and documentation generation."""
2
+
3
+ __version__ = "0.1.0"
repofuse/cli.py ADDED
@@ -0,0 +1,69 @@
1
+ """repofuse CLI — Typer-based command line interface."""
2
+
3
+ import typer
4
+ from pathlib import Path
5
+ from typing import Optional
6
+ from rich.console import Console
7
+ from repofuse.core import add_repo, list_repos, remove_repo, init_monorepo
8
+ from repofuse.isolate import isolate
9
+ from repofuse.docs import generate_docs
10
+
11
+ app = typer.Typer(help="repofuse — Monorepo management tool")
12
+ console = Console()
13
+
14
+ @app.command()
15
+ def init(
16
+ path: Path = typer.Argument(".", help="Project root"),
17
+ remote: Optional[str] = typer.Option(None, "--remote", "-r", help="Remote URL for config sync"),
18
+ ):
19
+ """Initialize a monorepo structure."""
20
+ init_monorepo(path, remote)
21
+ console.print(f"[green]OK[/green] Monorepo initialized at [bold]{path.resolve()}[/bold]")
22
+
23
+ @app.command()
24
+ def add(
25
+ url: str = typer.Argument(..., help="Git repository URL"),
26
+ prefix: str = typer.Option("", "--prefix", "-p", help="Subdirectory prefix (auto-detected if empty)"),
27
+ ref: str = typer.Option("main", "--ref", help="Branch/tag/commit to track"),
28
+ ):
29
+ """Add a sub-repository to the monorepo."""
30
+ result = add_repo(url, prefix=prefix or None, ref=ref)
31
+ console.print(f"[green]OK[/green] Added [bold]{result['name']}[/bold] -> [dim]{result['path']}[/dim]")
32
+
33
+ @app.command()
34
+ def list():
35
+ """List all registered sub-repositories."""
36
+ repos = list_repos()
37
+ if not repos:
38
+ console.print("[yellow]No sub-repositories registered.[/yellow]")
39
+ return
40
+ for r in repos:
41
+ console.print(f" [cyan]{r['name']}[/cyan] [dim]{r['path']}[/dim] ({r['ref']})")
42
+
43
+ @app.command()
44
+ def remove(
45
+ name: str = typer.Argument(..., help="Repository name to remove"),
46
+ keep_files: bool = typer.Option(False, "--keep-files", help="Keep files on disk"),
47
+ ):
48
+ """Remove a sub-repository from the monorepo."""
49
+ remove_repo(name, keep_files=keep_files)
50
+ console.print(f"[green]OK[/green] Removed [bold]{name}[/bold]")
51
+
52
+ @app.command()
53
+ def iso(
54
+ name: str = typer.Argument(..., help="Repository name to isolate"),
55
+ output: Path = typer.Option(Path("_isolated"), "--output", "-o", help="Output directory"),
56
+ ):
57
+ """Isolate a sub-repository as a standalone repo with full history."""
58
+ path = isolate(name, output)
59
+ console.print(f"[green]OK[/green] Isolated to [bold]{path}[/bold]")
60
+
61
+ @app.command()
62
+ def docs(
63
+ path: Path = typer.Argument(".", help="Repository path"),
64
+ output: Path = typer.Option(Path("docs"), "--output", "-o", help="Output directory"),
65
+ format: str = typer.Option("markdown", "--format", "-f", help="Output format (markdown/json)"),
66
+ ):
67
+ """Generate documentation from repository source code."""
68
+ result = generate_docs(path, output, fmt=format)
69
+ console.print(f"[green]OK[/green] Generated {result['file_count']} docs -> [bold]{output.resolve()}[/bold]")
repofuse/core.py ADDED
@@ -0,0 +1,91 @@
1
+ """repofuse core — Monorepo initialization, add/remove/list sub-repos."""
2
+
3
+ import json
4
+ import subprocess
5
+ from pathlib import Path
6
+ from typing import Optional
7
+ from repofuse.utils import git, repo_name_from_url, CONFIG_FILE
8
+
9
+
10
+ def init_monorepo(path: Path, remote: Optional[str] = None) -> dict:
11
+ path = Path(path).resolve()
12
+ path.mkdir(parents=True, exist_ok=True)
13
+
14
+ config_path = path / CONFIG_FILE
15
+ if config_path.exists():
16
+ raise FileExistsError(f"Monorepo already initialized at {path}")
17
+
18
+ config = {"version": "0.1.0", "remote": remote, "repos": {}}
19
+ config_path.write_text(json.dumps(config, indent=2))
20
+
21
+ if not (path / ".git").exists():
22
+ git(["init"], cwd=path)
23
+ if remote:
24
+ git(["remote", "add", "origin", remote], cwd=path)
25
+
26
+ return config
27
+
28
+
29
+ def add_repo(url: str, prefix: Optional[str] = None, ref: str = "main") -> dict:
30
+ config = _load_config()
31
+
32
+ name = repo_name_from_url(url)
33
+ target = prefix or name
34
+ dest = Path(target)
35
+
36
+ if name in config["repos"]:
37
+ raise ValueError(f"Repository '{name}' already registered")
38
+
39
+ if dest.exists():
40
+ raise FileExistsError(f"Path '{dest}' already exists")
41
+
42
+ dest.mkdir(parents=True, exist_ok=True)
43
+ git(["clone", "--depth", "1", "--branch", ref, url, str(dest)])
44
+ if (dest / ".git").exists():
45
+ _remove_git_history(dest)
46
+
47
+ entry = {"url": url, "path": str(dest), "ref": ref}
48
+ config["repos"][name] = entry
49
+ _save_config(config)
50
+ return {"name": name, **entry}
51
+
52
+
53
+ def list_repos() -> list[dict]:
54
+ config = _load_config()
55
+ result = []
56
+ for name, entry in config["repos"].items():
57
+ result.append({"name": name, **entry})
58
+ return result
59
+
60
+
61
+ def remove_repo(name: str, keep_files: bool = False) -> None:
62
+ config = _load_config()
63
+ if name not in config["repos"]:
64
+ raise KeyError(f"Repository '{name}' not found")
65
+
66
+ entry = config["repos"][name]
67
+ if not keep_files:
68
+ import shutil
69
+ shutil.rmtree(entry["path"], ignore_errors=True)
70
+
71
+ del config["repos"][name]
72
+ _save_config(config)
73
+
74
+
75
+ def _load_config() -> dict:
76
+ from repofuse.utils import find_config
77
+ config_path = find_config()
78
+ return json.loads(config_path.read_text("utf-8"))
79
+
80
+
81
+ def _save_config(config: dict) -> None:
82
+ from repofuse.utils import find_config
83
+ config_path = find_config()
84
+ config_path.write_text(json.dumps(config, indent=2, ensure_ascii=False))
85
+
86
+
87
+ def _remove_git_history(path: Path) -> None:
88
+ import shutil
89
+ git_dir = path / ".git"
90
+ if git_dir.exists():
91
+ shutil.rmtree(git_dir)
repofuse/docs.py ADDED
@@ -0,0 +1,81 @@
1
+ """repofuse docs — Generate documentation from repository source code."""
2
+
3
+ import ast
4
+ import json
5
+ from pathlib import Path
6
+
7
+
8
+ def generate_docs(path: Path, output: Path, fmt: str = "markdown") -> dict:
9
+ path = Path(path).resolve()
10
+ output = Path(output).resolve()
11
+ output.mkdir(parents=True, exist_ok=True)
12
+
13
+ files = sorted(path.rglob("*.py"))
14
+ generated = []
15
+
16
+ for filepath in files:
17
+ if any(part.startswith(".") or part == "__pycache__" for part in filepath.parts):
18
+ continue
19
+ rel = filepath.relative_to(path)
20
+ doc = _parse_python_file(filepath, rel)
21
+ if doc["functions"] or doc["classes"]:
22
+ generated.append(doc)
23
+
24
+ if fmt == "json":
25
+ (output / "docs.json").write_text(
26
+ json.dumps(generated, indent=2, ensure_ascii=False)
27
+ )
28
+ else:
29
+ _write_markdown(generated, output)
30
+
31
+ return {"file_count": len(generated), "output": str(output)}
32
+
33
+
34
+ def _parse_python_file(filepath: Path, rel: Path) -> dict:
35
+ try:
36
+ tree = ast.parse(filepath.read_text("utf-8"))
37
+ except SyntaxError:
38
+ return {"file": str(rel), "functions": [], "classes": []}
39
+
40
+ functions = []
41
+ classes = []
42
+
43
+ for node in ast.walk(tree):
44
+ if isinstance(node, ast.FunctionDef):
45
+ functions.append({
46
+ "name": node.name,
47
+ "docstring": ast.get_docstring(node) or "",
48
+ "line": node.lineno,
49
+ })
50
+ elif isinstance(node, ast.ClassDef):
51
+ classes.append({
52
+ "name": node.name,
53
+ "docstring": ast.get_docstring(node) or "",
54
+ "methods": [
55
+ {"name": n.name, "docstring": ast.get_docstring(n) or ""}
56
+ for n in node.body if isinstance(n, ast.FunctionDef)
57
+ ],
58
+ "line": node.lineno,
59
+ })
60
+
61
+ return {"file": str(rel), "functions": functions, "classes": classes}
62
+
63
+
64
+ def _write_markdown(docs: list[dict], output: Path) -> None:
65
+ for doc in docs:
66
+ md = output / Path(doc["file"]).with_suffix(".md")
67
+ md.parent.mkdir(parents=True, exist_ok=True)
68
+ lines = [f"# {doc['file']}\n"]
69
+ for cls in doc["classes"]:
70
+ lines.append(f"\n## Class: `{cls['name']}`\n")
71
+ if cls["docstring"]:
72
+ lines.append(f"{cls['docstring']}\n")
73
+ for m in cls["methods"]:
74
+ lines.append(f"### `{m['name']}()`\n")
75
+ if m["docstring"]:
76
+ lines.append(f"{m['docstring']}\n")
77
+ for fn in doc["functions"]:
78
+ lines.append(f"\n## `{fn['name']}()`\n")
79
+ if fn["docstring"]:
80
+ lines.append(f"{fn['docstring']}\n")
81
+ md.write_text("\n".join(lines), encoding="utf-8")
repofuse/isolate.py ADDED
@@ -0,0 +1,31 @@
1
+ """repofuse isolate — Extract a sub-repo as standalone with isolated files."""
2
+
3
+ import json
4
+ import shutil
5
+ from pathlib import Path
6
+ from repofuse.utils import find_config
7
+
8
+
9
+ def isolate(name: str, output: Path) -> Path:
10
+ config_path = find_config()
11
+ config = json.loads(config_path.read_text("utf-8"))
12
+
13
+ if name not in config["repos"]:
14
+ raise KeyError(f"Repository '{name}' not found in monorepo config")
15
+
16
+ entry = config["repos"][name]
17
+ source = (config_path.parent / entry["path"]).resolve()
18
+ dest = Path(output).resolve()
19
+
20
+ if dest.exists():
21
+ shutil.rmtree(dest)
22
+
23
+ shutil.copytree(source, dest)
24
+
25
+ init_file = dest / "__init__.py"
26
+ if not init_file.exists():
27
+ parent_init = source / "__init__.py"
28
+ if parent_init.exists():
29
+ shutil.copy2(parent_init, init_file)
30
+
31
+ return dest
repofuse/utils.py ADDED
@@ -0,0 +1,38 @@
1
+ """repofuse utilities."""
2
+
3
+ import subprocess
4
+ from pathlib import Path
5
+
6
+ CONFIG_FILE = ".repofuse.json"
7
+
8
+
9
+ def find_config() -> Path:
10
+ cwd = Path.cwd()
11
+ for parent in [cwd] + list(cwd.parents):
12
+ config = parent / CONFIG_FILE
13
+ if config.exists():
14
+ return config
15
+ raise FileNotFoundError(
16
+ f"No {CONFIG_FILE} found in any parent directory. "
17
+ "Run 'repofuse init' first."
18
+ )
19
+
20
+
21
+ def repo_name_from_url(url: str) -> str:
22
+ name = url.rstrip("/").split("/")[-1]
23
+ if name.endswith(".git"):
24
+ name = name[:-4]
25
+ return name
26
+
27
+
28
+ def git(args: list[str], cwd: Path) -> str:
29
+ result = subprocess.run(
30
+ ["git"] + args,
31
+ capture_output=True,
32
+ text=True,
33
+ cwd=str(cwd),
34
+ timeout=120,
35
+ )
36
+ if result.returncode != 0:
37
+ raise RuntimeError(f"git {' '.join(args)} failed: {result.stderr.strip()}")
38
+ return result.stdout.strip()
@@ -0,0 +1,68 @@
1
+ Metadata-Version: 2.4
2
+ Name: repofuse
3
+ Version: 0.1.0
4
+ Summary: Monorepo management: multi-repo isolation and documentation generation
5
+ Author-email: deepstrain <dev@massiron.com>
6
+ License: MIT
7
+ Project-URL: Homepage, https://github.com/massiron/repofuse
8
+ Project-URL: Repository, https://github.com/massiron/repofuse
9
+ Project-URL: Documentation, https://github.com/massiron/repofuse#readme
10
+ Keywords: monorepo,git,repository,documentation
11
+ Classifier: Development Status :: 3 - Alpha
12
+ Classifier: Intended Audience :: Developers
13
+ Classifier: License :: OSI Approved :: MIT License
14
+ Classifier: Programming Language :: Python :: 3
15
+ Classifier: Programming Language :: Python :: 3.10
16
+ Classifier: Programming Language :: Python :: 3.11
17
+ Classifier: Programming Language :: Python :: 3.12
18
+ Classifier: Programming Language :: Python :: 3.13
19
+ Requires-Python: >=3.10
20
+ Description-Content-Type: text/markdown
21
+ Requires-Dist: gitpython>=3.1
22
+ Requires-Dist: typer>=0.9
23
+ Requires-Dist: rich>=13.0
24
+ Requires-Dist: pyyaml>=6.0
25
+
26
+ # repofuse
27
+
28
+ **Monorepo management: multi-repo isolation and documentation generation.**
29
+
30
+ ## Installation
31
+
32
+ ```bash
33
+ pip install repofuse
34
+ ```
35
+
36
+ ## Usage
37
+
38
+ ```bash
39
+ # Initialize a monorepo
40
+ repofuse init
41
+
42
+ # Add a sub-repository
43
+ repofuse add https://github.com/user/project.git
44
+
45
+ # Add with custom prefix
46
+ repofuse add https://github.com/user/project.git --prefix libs/project
47
+
48
+ # List registered repos
49
+ repofuse list
50
+
51
+ # Isolate a sub-repo as standalone
52
+ repofuse iso project-name -o _isolated
53
+
54
+ # Generate documentation
55
+ repofuse docs -o docs/
56
+ ```
57
+
58
+ ## API
59
+
60
+ ```python
61
+ from repofuse.core import add_repo, list_repos, remove_repo
62
+ from repofuse.isolate import isolate
63
+ from repofuse.docs import generate_docs
64
+ ```
65
+
66
+ ## License
67
+
68
+ MIT
@@ -0,0 +1,11 @@
1
+ repofuse/__init__.py,sha256=AcqWJdYmcrMPljQFnJq6xEBODKSIO-tmVMDnOvL8aqI,114
2
+ repofuse/cli.py,sha256=IPnyvAw3C2p0wY4gqMD-_U2tiMjSzigQFflYuApDoR0,2803
3
+ repofuse/core.py,sha256=3TaMoohe8ZAQzwDzzFZMx4BVAJAvpgQ7sNU5ci60wk0,2593
4
+ repofuse/docs.py,sha256=6xWtQZzMebhOawnznXzNkiX5AWeDMwg5SKXAnj9QwVE,2778
5
+ repofuse/isolate.py,sha256=aky8IxPWKx13E5s7X4-Yx2dLFGoapWcemuxOK-vBkOY,849
6
+ repofuse/utils.py,sha256=82HX1sNGYhq9xKx_qhd95QG1wlKF0ZLXfGo0D0ou23w,921
7
+ repofuse-0.1.0.dist-info/METADATA,sha256=LizSAMOQmyn3yc-utpplENlL5Gh4f4c6b9S2L_ux0eY,1774
8
+ repofuse-0.1.0.dist-info/WHEEL,sha256=aeYiig01lYGDzBgS8HxWXOg3uV61G9ijOsup-k9o1sk,91
9
+ repofuse-0.1.0.dist-info/entry_points.txt,sha256=EXDxlS5W67Bczxoflp1kDcP-YT0bDQ_v4pHSfnyWZuk,46
10
+ repofuse-0.1.0.dist-info/top_level.txt,sha256=9kWH6MLWAFv8OxfO78kfyYfuvh23j5kDy0oEpjy3Tvw,9
11
+ repofuse-0.1.0.dist-info/RECORD,,
@@ -0,0 +1,5 @@
1
+ Wheel-Version: 1.0
2
+ Generator: setuptools (82.0.1)
3
+ Root-Is-Purelib: true
4
+ Tag: py3-none-any
5
+
@@ -0,0 +1,2 @@
1
+ [console_scripts]
2
+ repofuse = repofuse.cli:app
@@ -0,0 +1 @@
1
+ repofuse