pyoxidize 0.2.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.
Files changed (45) hide show
  1. oxidize/__init__.py +1 -0
  2. oxidize/cli/__init__.py +3 -0
  3. oxidize/cli/commands/__init__.py +0 -0
  4. oxidize/cli/commands/add.py +28 -0
  5. oxidize/cli/commands/commit.py +83 -0
  6. oxidize/cli/commands/diff.py +118 -0
  7. oxidize/cli/commands/init.py +18 -0
  8. oxidize/cli/commands/log.py +40 -0
  9. oxidize/cli/commands/scan.py +50 -0
  10. oxidize/cli/commands/status.py +75 -0
  11. oxidize/cli/commands/undo.py +65 -0
  12. oxidize/cli/main.py +26 -0
  13. oxidize/cli/repl.py +168 -0
  14. oxidize/core/__init__.py +5 -0
  15. oxidize/core/config.py +30 -0
  16. oxidize/core/refs.py +54 -0
  17. oxidize/core/repository.py +54 -0
  18. oxidize/diff/__init__.py +3 -0
  19. oxidize/diff/engine.py +91 -0
  20. oxidize/index/__init__.py +3 -0
  21. oxidize/index/staging.py +73 -0
  22. oxidize/merge/__init__.py +5 -0
  23. oxidize/merge/structured.py +55 -0
  24. oxidize/notebook/__init__.py +5 -0
  25. oxidize/notebook/differ.py +100 -0
  26. oxidize/objects/__init__.py +12 -0
  27. oxidize/objects/types.py +164 -0
  28. oxidize/provenance/__init__.py +5 -0
  29. oxidize/provenance/agent.py +84 -0
  30. oxidize/security/__init__.py +5 -0
  31. oxidize/security/scanner.py +65 -0
  32. oxidize/semantic/__init__.py +14 -0
  33. oxidize/semantic/differ.py +89 -0
  34. oxidize/semantic/entities.py +122 -0
  35. oxidize/storage/__init__.py +4 -0
  36. oxidize/storage/backend.py +51 -0
  37. oxidize/storage/database.py +45 -0
  38. oxidize/undo/__init__.py +5 -0
  39. oxidize/undo/journal.py +79 -0
  40. oxidize/undo/reverser.py +93 -0
  41. pyoxidize-0.2.0.dist-info/METADATA +118 -0
  42. pyoxidize-0.2.0.dist-info/RECORD +45 -0
  43. pyoxidize-0.2.0.dist-info/WHEEL +5 -0
  44. pyoxidize-0.2.0.dist-info/entry_points.txt +3 -0
  45. pyoxidize-0.2.0.dist-info/top_level.txt +1 -0
oxidize/__init__.py ADDED
@@ -0,0 +1 @@
1
+ __version__ = "0.2.0"
@@ -0,0 +1,3 @@
1
+ from .main import cli
2
+
3
+ __all__ = ["cli"]
File without changes
@@ -0,0 +1,28 @@
1
+ from pathlib import Path
2
+
3
+ import click
4
+
5
+ from oxidize.core.repository import Repository, RepositoryNotFound
6
+
7
+
8
+ @click.command("add")
9
+ @click.argument("paths", nargs=-1, required=True, type=click.Path(exists=True))
10
+ def cmd_add(paths: tuple[str, ...]) -> None:
11
+ """Stage files for the next commit."""
12
+ try:
13
+ repo = Repository.discover()
14
+ except RepositoryNotFound as e:
15
+ raise click.ClickException(str(e))
16
+
17
+ for raw_path in paths:
18
+ target = Path(raw_path).resolve()
19
+ if target.is_dir():
20
+ files = [f for f in target.rglob("*") if f.is_file()]
21
+ else:
22
+ files = [target]
23
+
24
+ for file_path in files:
25
+ rel = file_path.relative_to(repo.work_tree).as_posix()
26
+ oid = repo.db.store_blob(file_path.read_bytes())
27
+ repo.index.add(rel, oid, file_path)
28
+ click.echo(f" staged: {rel}")
@@ -0,0 +1,83 @@
1
+ from __future__ import annotations
2
+
3
+ import os
4
+ from collections import defaultdict
5
+
6
+ import click
7
+
8
+ from oxidize.core.repository import Repository, RepositoryNotFound
9
+ from oxidize.objects.types import Author, Commit, FileMode, Tree, TreeEntry
10
+
11
+
12
+ def _build_tree(repo: Repository) -> Tree:
13
+ entries_by_dir: dict[str, list[tuple[str, str, FileMode]]] = defaultdict(list)
14
+ for entry in repo.index.entries():
15
+ parts = entry.path.split("/")
16
+ if len(parts) == 1:
17
+ entries_by_dir[""].append((parts[0], entry.oid, FileMode(entry.mode)))
18
+ else:
19
+ dir_name = parts[0]
20
+ file_name = parts[-1]
21
+ entries_by_dir[dir_name].append((file_name, entry.oid, FileMode(entry.mode)))
22
+
23
+ subtrees: dict[str, str] = {}
24
+ for dir_name, dir_entries in entries_by_dir.items():
25
+ if dir_name and len(dir_entries) == 1 and dir_entries[0][0] == "__subtree__":
26
+ continue
27
+ if not dir_name:
28
+ continue
29
+ sub_tree = Tree()
30
+ for fname, foid, fmode in dir_entries:
31
+ sub_tree.add(TreeEntry(name=fname, oid=foid, mode=fmode))
32
+ repo.db.store_tree(sub_tree)
33
+ subtrees[dir_name] = sub_tree.oid
34
+
35
+ root = Tree()
36
+ for fname, foid, fmode in entries_by_dir.get("", []):
37
+ root.add(TreeEntry(name=fname, oid=foid, mode=fmode))
38
+ for dir_name, subtree_oid in sorted(subtrees.items()):
39
+ root.add(TreeEntry(name=dir_name, oid=subtree_oid, mode=FileMode.DIRECTORY))
40
+ return root
41
+
42
+
43
+ @click.command("commit")
44
+ @click.option("-m", "--message", required=True, help="Commit message")
45
+ @click.option("--agent", default=None, help="Agent that produced this change (e.g. claude-code)")
46
+ def cmd_commit(message: str, agent: str | None = None) -> None:
47
+ """Record staged changes as a new commit."""
48
+ try:
49
+ repo = Repository.discover()
50
+ except RepositoryNotFound as e:
51
+ raise click.ClickException(str(e))
52
+
53
+ if len(repo.index) == 0:
54
+ raise click.ClickException("nothing staged -- use `oxidize add` first")
55
+
56
+ name = os.environ.get("OXIDE_AUTHOR_NAME") or repo.config.user_name
57
+ email = os.environ.get("OXIDE_AUTHOR_EMAIL") or repo.config.user_email
58
+ author = Author(name=name, email=email)
59
+
60
+ tree = _build_tree(repo)
61
+ repo.db.store_tree(tree)
62
+
63
+ parents: list[str] = []
64
+ head = repo.refs.head()
65
+ if head:
66
+ parents.append(head)
67
+
68
+ commit = Commit(
69
+ tree_oid=tree.oid,
70
+ author=author,
71
+ committer=author,
72
+ message=message,
73
+ parents=parents,
74
+ agent=agent,
75
+ )
76
+ repo.db.store_commit(commit)
77
+ repo.refs.update_head(commit.oid)
78
+ repo.index.clear()
79
+
80
+ short = commit.oid[:8]
81
+ branch = repo.refs.current_branch() or "HEAD"
82
+ agent_str = f" [{agent}]" if agent else ""
83
+ click.echo(f"[{branch} {short}]{agent_str} {message}")
@@ -0,0 +1,118 @@
1
+ from __future__ import annotations
2
+
3
+ from pathlib import Path
4
+
5
+ from typing import Any, Mapping
6
+
7
+ import click
8
+ from rich.console import Console
9
+ from rich.text import Text
10
+
11
+ from oxidize.core.repository import Repository, RepositoryNotFound
12
+ from oxidize.diff.engine import LineOp, diff_lines
13
+
14
+ console = Console()
15
+
16
+
17
+ @click.command("diff")
18
+ @click.argument("paths", nargs=-1, type=click.Path(exists=True))
19
+ @click.option("--cached", is_flag=True, help="Show staged changes (index vs HEAD)")
20
+ def cmd_diff(paths: tuple[str, ...], cached: bool) -> None:
21
+ """Show changes between working tree and index (or staged vs HEAD)."""
22
+ try:
23
+ repo = Repository.discover()
24
+ except RepositoryNotFound as e:
25
+ raise click.ClickException(str(e))
26
+
27
+ if cached:
28
+ _diff_cached(repo, paths)
29
+ else:
30
+ _diff_working(repo, paths)
31
+
32
+
33
+ def _diff_working(repo: Repository, paths: tuple[str, ...]) -> None:
34
+ indexed = {e.path: e for e in repo.index.entries()}
35
+ files = _resolve_paths(repo, paths, indexed)
36
+
37
+ for rel in sorted(files):
38
+ entry = indexed.get(rel)
39
+ if entry is None:
40
+ continue
41
+ disk = repo.work_tree / rel
42
+ if not disk.exists():
43
+ console.print(f"[red]deleted: {rel}[/]")
44
+ continue
45
+ new_text = disk.read_text(errors="replace")
46
+ old_blob = repo.db.load_blob(entry.oid)
47
+ old_text = old_blob.data.decode(errors="replace")
48
+ if old_text == new_text:
49
+ continue
50
+ _print_diff(rel, old_text, new_text)
51
+
52
+
53
+ def _diff_cached(repo: Repository, paths: tuple[str, ...]) -> None:
54
+ head = repo.refs.head()
55
+ if not head:
56
+ console.print("[dim]no commits yet[/]")
57
+ return
58
+
59
+ commit = repo.db.load_commit(head)
60
+ head_tree = repo.db.load_tree(commit.tree_oid)
61
+ head_files = {e.name: e for e in head_tree}
62
+
63
+ indexed = {e.path: e for e in repo.index.entries()}
64
+ files = _resolve_paths(repo, paths, indexed)
65
+
66
+ for rel in sorted(files):
67
+ entry = indexed.get(rel)
68
+ head_entry = head_files.get(rel)
69
+ if entry and not head_entry:
70
+ console.print(f"[green]new file: {rel}[/]")
71
+ elif not entry and head_entry:
72
+ console.print(f"[red]deleted: {rel}[/]")
73
+ elif entry and head_entry:
74
+ old_blob = repo.db.load_blob(head_entry.oid)
75
+ new_blob = repo.db.load_blob(entry.oid)
76
+ if old_blob.data != new_blob.data:
77
+ _print_diff(
78
+ rel,
79
+ old_blob.data.decode(errors="replace"),
80
+ new_blob.data.decode(errors="replace"),
81
+ )
82
+
83
+
84
+ def _resolve_paths(
85
+ repo: Repository, paths: tuple[str, ...], indexed: Mapping[str, Any]
86
+ ) -> set[str]:
87
+ if paths:
88
+ result: set[str] = set()
89
+ for raw in paths:
90
+ p = Path(raw).resolve()
91
+ if p.is_dir():
92
+ for f in p.rglob("*"):
93
+ rel = f.relative_to(repo.work_tree).as_posix()
94
+ if rel in indexed:
95
+ result.add(rel)
96
+ else:
97
+ rel = p.relative_to(repo.work_tree).as_posix()
98
+ result.add(rel)
99
+ return result
100
+ return set(indexed.keys())
101
+
102
+
103
+ def _print_diff(path: str, old: str, new: str) -> None:
104
+ console.print(f"\n[bold]diff --a/a b/{path}[/]")
105
+ diff_result = diff_lines(old, new)
106
+ for dl in diff_result:
107
+ if dl.op == LineOp.EQUAL:
108
+ console.print(f" {dl.content}", end="")
109
+ elif dl.op == LineOp.INSERT:
110
+ line = Text(f"+{dl.content}")
111
+ line.stylize("green")
112
+ console.print(line, end="")
113
+ elif dl.op == LineOp.DELETE:
114
+ line = Text(f"-{dl.content}")
115
+ line.stylize("red")
116
+ console.print(line, end="")
117
+ if diff_result and not diff_result[-1].content.endswith("\n"):
118
+ console.print()
@@ -0,0 +1,18 @@
1
+ from pathlib import Path
2
+
3
+ import click
4
+
5
+ from oxidize.core.repository import Repository
6
+
7
+
8
+ @click.command("init")
9
+ @click.argument("path", default=".", type=click.Path())
10
+ def cmd_init(path: str) -> None:
11
+ """Initialize a new oxidize repository."""
12
+ target = Path(path).resolve()
13
+ target.mkdir(parents=True, exist_ok=True)
14
+ try:
15
+ repo = Repository.init(target)
16
+ click.echo(f"Initialized empty oxidize repository in {repo.oxidize_dir}")
17
+ except FileExistsError as e:
18
+ raise click.ClickException(str(e))
@@ -0,0 +1,40 @@
1
+ from __future__ import annotations
2
+
3
+ import datetime
4
+
5
+ import click
6
+ from rich.console import Console
7
+
8
+ from oxidize.core.repository import Repository, RepositoryNotFound
9
+
10
+ console = Console()
11
+
12
+
13
+ @click.command("log")
14
+ @click.option("-n", "--count", default=20, help="Max commits to show")
15
+ def cmd_log(count: int) -> None:
16
+ """Show commit history."""
17
+ try:
18
+ repo = Repository.discover()
19
+ except RepositoryNotFound as e:
20
+ raise click.ClickException(str(e))
21
+
22
+ head = repo.refs.head()
23
+ if not head:
24
+ click.echo("No commits yet.")
25
+ return
26
+
27
+ oid: str | None = head
28
+ shown = 0
29
+ while oid and shown < count:
30
+ commit = repo.db.load_commit(oid)
31
+ dt = datetime.datetime.fromtimestamp(commit.author.timestamp)
32
+ date_str = dt.strftime("%a %b %d %H:%M:%S %Y")
33
+
34
+ console.print(f"[yellow]commit {oid}[/]")
35
+ console.print(f"Author: {commit.author.name} <{commit.author.email}>")
36
+ console.print(f"Date: {date_str}\n")
37
+ console.print(f" {commit.message}\n")
38
+
39
+ oid = commit.parents[0] if commit.parents else None
40
+ shown += 1
@@ -0,0 +1,50 @@
1
+ from __future__ import annotations
2
+
3
+ from pathlib import Path
4
+
5
+ import click
6
+ from rich.console import Console
7
+
8
+ from oxidize.core.repository import Repository, RepositoryNotFound
9
+ from oxidize.security.scanner import scan_directory, scan_file
10
+
11
+ console = Console()
12
+
13
+
14
+ @click.command("scan")
15
+ @click.argument("paths", nargs=-1, type=click.Path(exists=True))
16
+ @click.option("--staged", is_flag=True, help="Scan only staged files")
17
+ def cmd_scan(paths: tuple[str, ...], staged: bool) -> None:
18
+ """Scan for secrets and credentials."""
19
+ try:
20
+ repo = Repository.discover()
21
+ except RepositoryNotFound as e:
22
+ raise click.ClickException(str(e))
23
+
24
+ findings: list[dict[str, str | int]] = []
25
+
26
+ if staged:
27
+ for entry in repo.index.entries():
28
+ full = repo.work_tree / entry.path
29
+ if full.exists():
30
+ findings.extend(scan_file(full, repo.work_tree))
31
+ elif paths:
32
+ for raw in paths:
33
+ p = Path(raw).resolve()
34
+ if p.is_dir():
35
+ findings.extend(scan_directory(p))
36
+ else:
37
+ findings.extend(scan_file(p, repo.work_tree))
38
+ else:
39
+ findings.extend(scan_directory(repo.work_tree))
40
+
41
+ if not findings:
42
+ console.print("[green]No secrets found.[/]")
43
+ return
44
+
45
+ console.print(f"\n[red bold]Found {len(findings)} potential secret(s):[/]\n")
46
+ for f in findings:
47
+ console.print(
48
+ f" [yellow]{f['type']}[/] in [cyan]{f['file']}[/] line {f['line']}: {f['match']}"
49
+ )
50
+ console.print()
@@ -0,0 +1,75 @@
1
+ from __future__ import annotations
2
+
3
+ from pathlib import Path
4
+
5
+ import click
6
+ from rich.console import Console
7
+
8
+ from oxidize.core.repository import Repository, RepositoryNotFound
9
+
10
+ console = Console()
11
+
12
+ _IGNORE = {".oxidize", ".git", "__pycache__", ".DS_Store"}
13
+
14
+
15
+ def _walk(root: Path) -> list[Path]:
16
+ result = []
17
+ for p in sorted(root.rglob("*")):
18
+ if any(part in _IGNORE for part in p.parts):
19
+ continue
20
+ if p.is_file():
21
+ result.append(p)
22
+ return result
23
+
24
+
25
+ @click.command("status")
26
+ def cmd_status() -> None:
27
+ """Show working tree status."""
28
+ try:
29
+ repo = Repository.discover()
30
+ except RepositoryNotFound as e:
31
+ raise click.ClickException(str(e))
32
+
33
+ branch = repo.refs.current_branch() or "(detached HEAD)"
34
+ console.print(f"On branch [bold cyan]{branch}[/]")
35
+
36
+ staged: list[str] = []
37
+ modified: list[str] = []
38
+ untracked: list[str] = []
39
+
40
+ indexed_paths = {e.path for e in repo.index.entries()}
41
+ disk_files = _walk(repo.work_tree)
42
+
43
+ for file_path in disk_files:
44
+ rel = file_path.relative_to(repo.work_tree).as_posix()
45
+ entry = repo.index.get(rel)
46
+ if entry is None:
47
+ untracked.append(rel)
48
+ elif entry.is_stale(file_path):
49
+ modified.append(rel)
50
+
51
+ for path in indexed_paths:
52
+ full = repo.work_tree / path
53
+ if not full.exists():
54
+ staged.append(f"deleted: {path}")
55
+ else:
56
+ staged.append(path)
57
+
58
+ if not staged and not modified and not untracked:
59
+ console.print("nothing to commit, working tree clean")
60
+ return
61
+
62
+ if staged:
63
+ console.print("\n[green]Changes staged for commit:[/]")
64
+ for p in staged:
65
+ console.print(f" [green]{p}[/]")
66
+
67
+ if modified:
68
+ console.print("\n[yellow]Changes not staged for commit:[/]")
69
+ for p in modified:
70
+ console.print(f" [yellow]modified: {p}[/]")
71
+
72
+ if untracked:
73
+ console.print("\n[dim]Untracked files:[/]")
74
+ for p in untracked:
75
+ console.print(f" [dim]{p}[/]")
@@ -0,0 +1,65 @@
1
+ from __future__ import annotations
2
+
3
+ import time
4
+
5
+ import click
6
+ from rich.console import Console
7
+
8
+ from oxidize.core.repository import Repository, RepositoryNotFound
9
+ from oxidize.undo.journal import Journal
10
+ from oxidize.undo.reverser import UndoManager
11
+
12
+ console = Console()
13
+
14
+
15
+ @click.group(invoke_without_command=True)
16
+ @click.pass_context
17
+ def cmd_undo(ctx: click.Context) -> None:
18
+ """Undo the last operation."""
19
+ if ctx.invoked_subcommand is None:
20
+ _do_undo(1)
21
+
22
+
23
+ @cmd_undo.command("last")
24
+ def cmd_undo_last() -> None:
25
+ """Undo the last operation."""
26
+ _do_undo(1)
27
+
28
+
29
+ @cmd_undo.command("count")
30
+ @click.argument("n", type=int)
31
+ def cmd_undo_count(n: int) -> None:
32
+ """Undo the last N operations."""
33
+ _do_undo(n)
34
+
35
+
36
+ @cmd_undo.command("journal")
37
+ def cmd_journal() -> None:
38
+ """Show operation journal."""
39
+ try:
40
+ repo = Repository.discover()
41
+ except RepositoryNotFound as e:
42
+ raise click.ClickException(str(e))
43
+
44
+ journal = Journal(repo.oxidize_dir / "journal.json")
45
+ entries = journal.entries()
46
+
47
+ if not entries:
48
+ click.echo("No operations recorded.")
49
+ return
50
+
51
+ for i, entry in enumerate(reversed(entries)):
52
+ dt = time.strftime("%Y-%m-%d %H:%M:%S", time.localtime(entry.timestamp))
53
+ click.echo(f" {i + 1}. [{dt}] {entry.op} - {entry.data}")
54
+
55
+
56
+ def _do_undo(count: int) -> None:
57
+ try:
58
+ repo = Repository.discover()
59
+ except RepositoryNotFound as e:
60
+ raise click.ClickException(str(e))
61
+
62
+ mgr = UndoManager(repo)
63
+ messages = mgr.undo(count)
64
+ for msg in messages:
65
+ click.echo(msg)
oxidize/cli/main.py ADDED
@@ -0,0 +1,26 @@
1
+ import click
2
+
3
+ from oxidize.cli.commands.add import cmd_add
4
+ from oxidize.cli.commands.commit import cmd_commit
5
+ from oxidize.cli.commands.diff import cmd_diff
6
+ from oxidize.cli.commands.init import cmd_init
7
+ from oxidize.cli.commands.log import cmd_log
8
+ from oxidize.cli.commands.scan import cmd_scan
9
+ from oxidize.cli.commands.status import cmd_status
10
+ from oxidize.cli.commands.undo import cmd_undo
11
+
12
+
13
+ @click.group()
14
+ @click.version_option(package_name="oxidize")
15
+ def cli() -> None:
16
+ """oxidize -- a version control system with semantic awareness."""
17
+
18
+
19
+ cli.add_command(cmd_init, "init")
20
+ cli.add_command(cmd_add, "add")
21
+ cli.add_command(cmd_status, "status")
22
+ cli.add_command(cmd_commit, "commit")
23
+ cli.add_command(cmd_log, "log")
24
+ cli.add_command(cmd_diff, "diff")
25
+ cli.add_command(cmd_scan, "scan")
26
+ cli.add_command(cmd_undo, "undo")
oxidize/cli/repl.py ADDED
@@ -0,0 +1,168 @@
1
+ from __future__ import annotations
2
+
3
+ from pathlib import Path
4
+
5
+ from prompt_toolkit import PromptSession
6
+ from prompt_toolkit.completion import NestedCompleter, PathCompleter
7
+ from prompt_toolkit.formatted_text import HTML
8
+ from prompt_toolkit.history import FileHistory
9
+ from prompt_toolkit.styles import Style
10
+
11
+ from oxidize.core.repository import Repository, RepositoryNotFound
12
+
13
+ _STYLE = Style.from_dict(
14
+ {
15
+ "toolbar": "#ffffff bg:#333333 bold",
16
+ "prompt": "#00aa00 bold",
17
+ }
18
+ )
19
+
20
+ _HISTORY_PATH = Path.home() / ".oxidize_history"
21
+
22
+
23
+ def _get_toolbar() -> HTML:
24
+ try:
25
+ repo = Repository.discover()
26
+ branch = repo.refs.current_branch() or "detached"
27
+ staged = len(repo.index)
28
+ head = repo.refs.head()
29
+ short = head[:8] if head else "none"
30
+ return HTML(
31
+ f" <b>({branch})</b> "
32
+ f"staged:<b>{staged}</b> "
33
+ f"HEAD:<b>{short}</b> "
34
+ f"| <b>Ctrl-D</b> exit | <b>help</b> for commands"
35
+ )
36
+ except RepositoryNotFound:
37
+ return HTML(" <b>no repo</b> | <b>oxidize init</b> to create one | <b>Ctrl-D</b> exit")
38
+
39
+
40
+ def _build_completer() -> NestedCompleter:
41
+ command_names = [
42
+ "init",
43
+ "add",
44
+ "status",
45
+ "commit",
46
+ "log",
47
+ "diff",
48
+ "scan",
49
+ "help",
50
+ "exit",
51
+ "quit",
52
+ "undo",
53
+ "redo",
54
+ "branch",
55
+ "agent",
56
+ "blame",
57
+ "merge",
58
+ "show",
59
+ ]
60
+ alias_map: dict[str, None] = {
61
+ "s": None,
62
+ "c": None,
63
+ "a": None,
64
+ "l": None,
65
+ "d": None,
66
+ "q": None,
67
+ }
68
+ nested: dict[str, PathCompleter | None] = {cmd: PathCompleter() for cmd in command_names}
69
+ nested.update(alias_map)
70
+ return NestedCompleter.from_nested_dict(nested)
71
+
72
+
73
+ _ALIASES: dict[str, str] = {
74
+ "s": "status",
75
+ "c": "commit",
76
+ "a": "add",
77
+ "l": "log",
78
+ "d": "diff",
79
+ "q": "exit",
80
+ }
81
+
82
+
83
+ def _dispatch(line: str) -> str | None:
84
+ parts = line.strip().split()
85
+ if not parts:
86
+ return None
87
+
88
+ cmd = _ALIASES.get(parts[0], parts[0])
89
+ args = parts[1:]
90
+
91
+ if cmd in ("exit", "quit"):
92
+ return "__EXIT__"
93
+
94
+ if cmd == "help":
95
+ return _help_text()
96
+
97
+ try:
98
+ from click.testing import CliRunner
99
+ from oxidize.cli.main import cli
100
+
101
+ runner = CliRunner()
102
+ result = runner.invoke(cli, [cmd] + args, catch_exceptions=False)
103
+ return result.output
104
+ except SystemExit as e:
105
+ return f"(exit code {e.code})" if e.code else None
106
+ except Exception as e:
107
+ return f"error: {e}"
108
+
109
+
110
+ def _help_text() -> str:
111
+ return """\
112
+ Available commands:
113
+ init [path] Initialize a new repository
114
+ add <paths...> Stage files for commit
115
+ status (s) Show working tree status
116
+ commit -m "msg" Record staged changes (c)
117
+ log [-n N] Show commit history (l)
118
+ diff [paths] Show changes (d)
119
+ scan [paths] Scan for secrets
120
+ help Show this help
121
+ exit (q) Exit the shell
122
+
123
+ Aliases: s=status, c=commit, a=add, l=log, d=diff, q=exit
124
+ """
125
+
126
+
127
+ def main() -> None:
128
+ import sys
129
+
130
+ args = sys.argv[1:]
131
+ if args:
132
+ from oxidize.cli.main import cli
133
+
134
+ cli(args, standalone_mode=True)
135
+ return
136
+
137
+ session: PromptSession[str] = PromptSession(
138
+ history=FileHistory(str(_HISTORY_PATH)),
139
+ completer=_build_completer(),
140
+ complete_while_typing=True,
141
+ style=_STYLE,
142
+ bottom_toolbar=_get_toolbar,
143
+ )
144
+
145
+ print("oxidize interactive shell v0.2.0")
146
+ print("Type 'help' for commands, Ctrl-D to exit.\n")
147
+
148
+ while True:
149
+ try:
150
+ text = session.prompt("oxi> ")
151
+ except KeyboardInterrupt:
152
+ continue
153
+ except EOFError:
154
+ break
155
+
156
+ text = text.strip()
157
+ if not text:
158
+ continue
159
+
160
+ output = _dispatch(text)
161
+ if output == "__EXIT__":
162
+ break
163
+ if output:
164
+ print(output, end="" if output.endswith("\n") else "\n")
165
+
166
+
167
+ if __name__ == "__main__":
168
+ main()
@@ -0,0 +1,5 @@
1
+ from .repository import Repository, RepositoryNotFound
2
+ from .config import Config
3
+ from .refs import RefStore
4
+
5
+ __all__ = ["Repository", "RepositoryNotFound", "Config", "RefStore"]