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.
- oxidize/__init__.py +1 -0
- oxidize/cli/__init__.py +3 -0
- oxidize/cli/commands/__init__.py +0 -0
- oxidize/cli/commands/add.py +28 -0
- oxidize/cli/commands/commit.py +83 -0
- oxidize/cli/commands/diff.py +118 -0
- oxidize/cli/commands/init.py +18 -0
- oxidize/cli/commands/log.py +40 -0
- oxidize/cli/commands/scan.py +50 -0
- oxidize/cli/commands/status.py +75 -0
- oxidize/cli/commands/undo.py +65 -0
- oxidize/cli/main.py +26 -0
- oxidize/cli/repl.py +168 -0
- oxidize/core/__init__.py +5 -0
- oxidize/core/config.py +30 -0
- oxidize/core/refs.py +54 -0
- oxidize/core/repository.py +54 -0
- oxidize/diff/__init__.py +3 -0
- oxidize/diff/engine.py +91 -0
- oxidize/index/__init__.py +3 -0
- oxidize/index/staging.py +73 -0
- oxidize/merge/__init__.py +5 -0
- oxidize/merge/structured.py +55 -0
- oxidize/notebook/__init__.py +5 -0
- oxidize/notebook/differ.py +100 -0
- oxidize/objects/__init__.py +12 -0
- oxidize/objects/types.py +164 -0
- oxidize/provenance/__init__.py +5 -0
- oxidize/provenance/agent.py +84 -0
- oxidize/security/__init__.py +5 -0
- oxidize/security/scanner.py +65 -0
- oxidize/semantic/__init__.py +14 -0
- oxidize/semantic/differ.py +89 -0
- oxidize/semantic/entities.py +122 -0
- oxidize/storage/__init__.py +4 -0
- oxidize/storage/backend.py +51 -0
- oxidize/storage/database.py +45 -0
- oxidize/undo/__init__.py +5 -0
- oxidize/undo/journal.py +79 -0
- oxidize/undo/reverser.py +93 -0
- pyoxidize-0.2.0.dist-info/METADATA +118 -0
- pyoxidize-0.2.0.dist-info/RECORD +45 -0
- pyoxidize-0.2.0.dist-info/WHEEL +5 -0
- pyoxidize-0.2.0.dist-info/entry_points.txt +3 -0
- pyoxidize-0.2.0.dist-info/top_level.txt +1 -0
oxidize/__init__.py
ADDED
|
@@ -0,0 +1 @@
|
|
|
1
|
+
__version__ = "0.2.0"
|
oxidize/cli/__init__.py
ADDED
|
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()
|