pyoxidize 0.2.0__tar.gz
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.
- pyoxidize-0.2.0/PKG-INFO +118 -0
- pyoxidize-0.2.0/README.md +96 -0
- pyoxidize-0.2.0/oxidize/__init__.py +1 -0
- pyoxidize-0.2.0/oxidize/cli/__init__.py +3 -0
- pyoxidize-0.2.0/oxidize/cli/commands/__init__.py +0 -0
- pyoxidize-0.2.0/oxidize/cli/commands/add.py +28 -0
- pyoxidize-0.2.0/oxidize/cli/commands/commit.py +83 -0
- pyoxidize-0.2.0/oxidize/cli/commands/diff.py +118 -0
- pyoxidize-0.2.0/oxidize/cli/commands/init.py +18 -0
- pyoxidize-0.2.0/oxidize/cli/commands/log.py +40 -0
- pyoxidize-0.2.0/oxidize/cli/commands/scan.py +50 -0
- pyoxidize-0.2.0/oxidize/cli/commands/status.py +75 -0
- pyoxidize-0.2.0/oxidize/cli/commands/undo.py +65 -0
- pyoxidize-0.2.0/oxidize/cli/main.py +26 -0
- pyoxidize-0.2.0/oxidize/cli/repl.py +168 -0
- pyoxidize-0.2.0/oxidize/core/__init__.py +5 -0
- pyoxidize-0.2.0/oxidize/core/config.py +30 -0
- pyoxidize-0.2.0/oxidize/core/refs.py +54 -0
- pyoxidize-0.2.0/oxidize/core/repository.py +54 -0
- pyoxidize-0.2.0/oxidize/diff/__init__.py +3 -0
- pyoxidize-0.2.0/oxidize/diff/engine.py +91 -0
- pyoxidize-0.2.0/oxidize/index/__init__.py +3 -0
- pyoxidize-0.2.0/oxidize/index/staging.py +73 -0
- pyoxidize-0.2.0/oxidize/merge/__init__.py +5 -0
- pyoxidize-0.2.0/oxidize/merge/structured.py +55 -0
- pyoxidize-0.2.0/oxidize/notebook/__init__.py +5 -0
- pyoxidize-0.2.0/oxidize/notebook/differ.py +100 -0
- pyoxidize-0.2.0/oxidize/objects/__init__.py +12 -0
- pyoxidize-0.2.0/oxidize/objects/types.py +164 -0
- pyoxidize-0.2.0/oxidize/provenance/__init__.py +5 -0
- pyoxidize-0.2.0/oxidize/provenance/agent.py +84 -0
- pyoxidize-0.2.0/oxidize/security/__init__.py +5 -0
- pyoxidize-0.2.0/oxidize/security/scanner.py +65 -0
- pyoxidize-0.2.0/oxidize/semantic/__init__.py +14 -0
- pyoxidize-0.2.0/oxidize/semantic/differ.py +89 -0
- pyoxidize-0.2.0/oxidize/semantic/entities.py +122 -0
- pyoxidize-0.2.0/oxidize/storage/__init__.py +4 -0
- pyoxidize-0.2.0/oxidize/storage/backend.py +51 -0
- pyoxidize-0.2.0/oxidize/storage/database.py +45 -0
- pyoxidize-0.2.0/oxidize/undo/__init__.py +5 -0
- pyoxidize-0.2.0/oxidize/undo/journal.py +79 -0
- pyoxidize-0.2.0/oxidize/undo/reverser.py +93 -0
- pyoxidize-0.2.0/pyoxidize.egg-info/PKG-INFO +118 -0
- pyoxidize-0.2.0/pyoxidize.egg-info/SOURCES.txt +59 -0
- pyoxidize-0.2.0/pyoxidize.egg-info/dependency_links.txt +1 -0
- pyoxidize-0.2.0/pyoxidize.egg-info/entry_points.txt +3 -0
- pyoxidize-0.2.0/pyoxidize.egg-info/requires.txt +17 -0
- pyoxidize-0.2.0/pyoxidize.egg-info/top_level.txt +1 -0
- pyoxidize-0.2.0/pyproject.toml +53 -0
- pyoxidize-0.2.0/setup.cfg +4 -0
- pyoxidize-0.2.0/tests/test_diff.py +28 -0
- pyoxidize-0.2.0/tests/test_index.py +35 -0
- pyoxidize-0.2.0/tests/test_merge.py +70 -0
- pyoxidize-0.2.0/tests/test_notebook.py +106 -0
- pyoxidize-0.2.0/tests/test_objects.py +48 -0
- pyoxidize-0.2.0/tests/test_provenance.py +42 -0
- pyoxidize-0.2.0/tests/test_repository.py +33 -0
- pyoxidize-0.2.0/tests/test_scanner.py +65 -0
- pyoxidize-0.2.0/tests/test_semantic.py +73 -0
- pyoxidize-0.2.0/tests/test_storage.py +38 -0
- pyoxidize-0.2.0/tests/test_undo.py +64 -0
pyoxidize-0.2.0/PKG-INFO
ADDED
|
@@ -0,0 +1,118 @@
|
|
|
1
|
+
Metadata-Version: 2.4
|
|
2
|
+
Name: pyoxidize
|
|
3
|
+
Version: 0.2.0
|
|
4
|
+
Summary: a version control system with semantic awareness built-in undo and interactive shell
|
|
5
|
+
License: MIT
|
|
6
|
+
Requires-Python: >=3.11
|
|
7
|
+
Description-Content-Type: text/markdown
|
|
8
|
+
Requires-Dist: click>=8.1
|
|
9
|
+
Requires-Dist: rich>=13.0
|
|
10
|
+
Requires-Dist: prompt_toolkit>=3.0
|
|
11
|
+
Requires-Dist: deepmerge>=1.1
|
|
12
|
+
Provides-Extra: semantic
|
|
13
|
+
Requires-Dist: py-tree-sitter>=0.21; extra == "semantic"
|
|
14
|
+
Requires-Dist: tree-sitter-python>=0.21; extra == "semantic"
|
|
15
|
+
Provides-Extra: notebook
|
|
16
|
+
Requires-Dist: nbformat>=5.9; extra == "notebook"
|
|
17
|
+
Provides-Extra: dev
|
|
18
|
+
Requires-Dist: pytest>=7.4; extra == "dev"
|
|
19
|
+
Requires-Dist: pytest-cov>=4.1; extra == "dev"
|
|
20
|
+
Requires-Dist: mypy>=1.5; extra == "dev"
|
|
21
|
+
Requires-Dist: ruff>=0.1; extra == "dev"
|
|
22
|
+
|
|
23
|
+
# oxidize
|
|
24
|
+
|
|
25
|
+
a version control system built from scratch with semantic awareness built-in undo and an interactive shell
|
|
26
|
+
|
|
27
|
+
[](https://github.com/siddhant-bayas/oxidize/actions/workflows/ci.yml)
|
|
28
|
+
[](https://pypi.org/project/oxidize/)
|
|
29
|
+
[](https://pypi.org/project/oxidize/)
|
|
30
|
+
|
|
31
|
+
## what it does
|
|
32
|
+
|
|
33
|
+
tracks file snapshots using content-addressable storage (sha-256) a commit dag and a staging index but unlike git it also understands your codes structure
|
|
34
|
+
|
|
35
|
+
```bash
|
|
36
|
+
# install
|
|
37
|
+
pip install oxidize
|
|
38
|
+
|
|
39
|
+
# or with extras
|
|
40
|
+
pip install "oxidize[semantic]"
|
|
41
|
+
pip install "oxidize[notebook]"
|
|
42
|
+
pip install "oxidize[dev]"
|
|
43
|
+
|
|
44
|
+
# usage
|
|
45
|
+
oxidize init
|
|
46
|
+
oxidize add file.py
|
|
47
|
+
oxidize status
|
|
48
|
+
oxidize commit -m "first commit"
|
|
49
|
+
oxidize log
|
|
50
|
+
|
|
51
|
+
# or use oxi as a shorter alias for everything
|
|
52
|
+
oxi init
|
|
53
|
+
oxi add file.py
|
|
54
|
+
oxi status
|
|
55
|
+
oxi commit -m "first commit"
|
|
56
|
+
oxi log
|
|
57
|
+
|
|
58
|
+
# interactive shell (no args launches the repl)
|
|
59
|
+
oxi
|
|
60
|
+
```
|
|
61
|
+
|
|
62
|
+
both `oxidize` and `oxi` accept the same subcommands
|
|
63
|
+
|
|
64
|
+
## features
|
|
65
|
+
|
|
66
|
+
* content-addressable object store -- sha-256 zlib-compressed deduplicated
|
|
67
|
+
* recursive tree objects -- proper nested directory tracking
|
|
68
|
+
* interactive repl -- oxi launches a shell with tab completion live status bar command history
|
|
69
|
+
* secret scanning -- detects api keys tokens and credentials before commit
|
|
70
|
+
* structured data merge -- json/yaml/toml merge at the key level not text lines
|
|
71
|
+
* notebook-aware versioning -- cell-level diffs for ipynb files auto-strip outputs
|
|
72
|
+
* ai agent provenance -- track which agent/tool wrote what code
|
|
73
|
+
* built-in undo -- every operation is safely reversible
|
|
74
|
+
* semantic diffs (experimental) -- ast-aware diffs that understand function renames
|
|
75
|
+
|
|
76
|
+
## how it works
|
|
77
|
+
|
|
78
|
+
every file you add gets hashed using sha-256 and stored as a blob inside .oxidize/objects/
|
|
79
|
+
|
|
80
|
+
a commit is just
|
|
81
|
+
|
|
82
|
+
* a snapshot of the file tree (with proper subtrees for nested dirs)
|
|
83
|
+
* metadata (author timestamp agent provenance)
|
|
84
|
+
* a pointer to the previous commit
|
|
85
|
+
|
|
86
|
+
```txt
|
|
87
|
+
files -> blobs -> tree -> commit -> commit -> commit
|
|
88
|
+
^
|
|
89
|
+
head
|
|
90
|
+
```
|
|
91
|
+
|
|
92
|
+
## structure
|
|
93
|
+
|
|
94
|
+
```txt
|
|
95
|
+
oxidize/
|
|
96
|
+
objects/ blob tree commit types and serialization
|
|
97
|
+
storage/ content-addressable object store
|
|
98
|
+
index/ staging area
|
|
99
|
+
core/ repo context refs config
|
|
100
|
+
diff/ myers diff engine
|
|
101
|
+
merge/ structured data merge (json/yaml/toml)
|
|
102
|
+
notebook/ ipynb cell-level support
|
|
103
|
+
semantic/ ast-aware diffs via tree-sitter
|
|
104
|
+
security/ secret scanning
|
|
105
|
+
provenance/ ai agent tracking
|
|
106
|
+
undo/ operation journal and reversal
|
|
107
|
+
cli/ click commands and prompt_toolkit repl
|
|
108
|
+
```
|
|
109
|
+
|
|
110
|
+
## contributing
|
|
111
|
+
|
|
112
|
+
see [docs/contributing.md](docs/contributing.md)
|
|
113
|
+
|
|
114
|
+
## license
|
|
115
|
+
|
|
116
|
+
mit
|
|
117
|
+
|
|
118
|
+
~ written by a guy who discovered hashing yesterday
|
|
@@ -0,0 +1,96 @@
|
|
|
1
|
+
# oxidize
|
|
2
|
+
|
|
3
|
+
a version control system built from scratch with semantic awareness built-in undo and an interactive shell
|
|
4
|
+
|
|
5
|
+
[](https://github.com/siddhant-bayas/oxidize/actions/workflows/ci.yml)
|
|
6
|
+
[](https://pypi.org/project/oxidize/)
|
|
7
|
+
[](https://pypi.org/project/oxidize/)
|
|
8
|
+
|
|
9
|
+
## what it does
|
|
10
|
+
|
|
11
|
+
tracks file snapshots using content-addressable storage (sha-256) a commit dag and a staging index but unlike git it also understands your codes structure
|
|
12
|
+
|
|
13
|
+
```bash
|
|
14
|
+
# install
|
|
15
|
+
pip install oxidize
|
|
16
|
+
|
|
17
|
+
# or with extras
|
|
18
|
+
pip install "oxidize[semantic]"
|
|
19
|
+
pip install "oxidize[notebook]"
|
|
20
|
+
pip install "oxidize[dev]"
|
|
21
|
+
|
|
22
|
+
# usage
|
|
23
|
+
oxidize init
|
|
24
|
+
oxidize add file.py
|
|
25
|
+
oxidize status
|
|
26
|
+
oxidize commit -m "first commit"
|
|
27
|
+
oxidize log
|
|
28
|
+
|
|
29
|
+
# or use oxi as a shorter alias for everything
|
|
30
|
+
oxi init
|
|
31
|
+
oxi add file.py
|
|
32
|
+
oxi status
|
|
33
|
+
oxi commit -m "first commit"
|
|
34
|
+
oxi log
|
|
35
|
+
|
|
36
|
+
# interactive shell (no args launches the repl)
|
|
37
|
+
oxi
|
|
38
|
+
```
|
|
39
|
+
|
|
40
|
+
both `oxidize` and `oxi` accept the same subcommands
|
|
41
|
+
|
|
42
|
+
## features
|
|
43
|
+
|
|
44
|
+
* content-addressable object store -- sha-256 zlib-compressed deduplicated
|
|
45
|
+
* recursive tree objects -- proper nested directory tracking
|
|
46
|
+
* interactive repl -- oxi launches a shell with tab completion live status bar command history
|
|
47
|
+
* secret scanning -- detects api keys tokens and credentials before commit
|
|
48
|
+
* structured data merge -- json/yaml/toml merge at the key level not text lines
|
|
49
|
+
* notebook-aware versioning -- cell-level diffs for ipynb files auto-strip outputs
|
|
50
|
+
* ai agent provenance -- track which agent/tool wrote what code
|
|
51
|
+
* built-in undo -- every operation is safely reversible
|
|
52
|
+
* semantic diffs (experimental) -- ast-aware diffs that understand function renames
|
|
53
|
+
|
|
54
|
+
## how it works
|
|
55
|
+
|
|
56
|
+
every file you add gets hashed using sha-256 and stored as a blob inside .oxidize/objects/
|
|
57
|
+
|
|
58
|
+
a commit is just
|
|
59
|
+
|
|
60
|
+
* a snapshot of the file tree (with proper subtrees for nested dirs)
|
|
61
|
+
* metadata (author timestamp agent provenance)
|
|
62
|
+
* a pointer to the previous commit
|
|
63
|
+
|
|
64
|
+
```txt
|
|
65
|
+
files -> blobs -> tree -> commit -> commit -> commit
|
|
66
|
+
^
|
|
67
|
+
head
|
|
68
|
+
```
|
|
69
|
+
|
|
70
|
+
## structure
|
|
71
|
+
|
|
72
|
+
```txt
|
|
73
|
+
oxidize/
|
|
74
|
+
objects/ blob tree commit types and serialization
|
|
75
|
+
storage/ content-addressable object store
|
|
76
|
+
index/ staging area
|
|
77
|
+
core/ repo context refs config
|
|
78
|
+
diff/ myers diff engine
|
|
79
|
+
merge/ structured data merge (json/yaml/toml)
|
|
80
|
+
notebook/ ipynb cell-level support
|
|
81
|
+
semantic/ ast-aware diffs via tree-sitter
|
|
82
|
+
security/ secret scanning
|
|
83
|
+
provenance/ ai agent tracking
|
|
84
|
+
undo/ operation journal and reversal
|
|
85
|
+
cli/ click commands and prompt_toolkit repl
|
|
86
|
+
```
|
|
87
|
+
|
|
88
|
+
## contributing
|
|
89
|
+
|
|
90
|
+
see [docs/contributing.md](docs/contributing.md)
|
|
91
|
+
|
|
92
|
+
## license
|
|
93
|
+
|
|
94
|
+
mit
|
|
95
|
+
|
|
96
|
+
~ written by a guy who discovered hashing yesterday
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
__version__ = "0.2.0"
|
|
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}[/]")
|