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 +3 -0
- repofuse/cli.py +69 -0
- repofuse/core.py +91 -0
- repofuse/docs.py +81 -0
- repofuse/isolate.py +31 -0
- repofuse/utils.py +38 -0
- repofuse-0.1.0.dist-info/METADATA +68 -0
- repofuse-0.1.0.dist-info/RECORD +11 -0
- repofuse-0.1.0.dist-info/WHEEL +5 -0
- repofuse-0.1.0.dist-info/entry_points.txt +2 -0
- repofuse-0.1.0.dist-info/top_level.txt +1 -0
repofuse/__init__.py
ADDED
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 @@
|
|
|
1
|
+
repofuse
|