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.
Files changed (61) hide show
  1. pyoxidize-0.2.0/PKG-INFO +118 -0
  2. pyoxidize-0.2.0/README.md +96 -0
  3. pyoxidize-0.2.0/oxidize/__init__.py +1 -0
  4. pyoxidize-0.2.0/oxidize/cli/__init__.py +3 -0
  5. pyoxidize-0.2.0/oxidize/cli/commands/__init__.py +0 -0
  6. pyoxidize-0.2.0/oxidize/cli/commands/add.py +28 -0
  7. pyoxidize-0.2.0/oxidize/cli/commands/commit.py +83 -0
  8. pyoxidize-0.2.0/oxidize/cli/commands/diff.py +118 -0
  9. pyoxidize-0.2.0/oxidize/cli/commands/init.py +18 -0
  10. pyoxidize-0.2.0/oxidize/cli/commands/log.py +40 -0
  11. pyoxidize-0.2.0/oxidize/cli/commands/scan.py +50 -0
  12. pyoxidize-0.2.0/oxidize/cli/commands/status.py +75 -0
  13. pyoxidize-0.2.0/oxidize/cli/commands/undo.py +65 -0
  14. pyoxidize-0.2.0/oxidize/cli/main.py +26 -0
  15. pyoxidize-0.2.0/oxidize/cli/repl.py +168 -0
  16. pyoxidize-0.2.0/oxidize/core/__init__.py +5 -0
  17. pyoxidize-0.2.0/oxidize/core/config.py +30 -0
  18. pyoxidize-0.2.0/oxidize/core/refs.py +54 -0
  19. pyoxidize-0.2.0/oxidize/core/repository.py +54 -0
  20. pyoxidize-0.2.0/oxidize/diff/__init__.py +3 -0
  21. pyoxidize-0.2.0/oxidize/diff/engine.py +91 -0
  22. pyoxidize-0.2.0/oxidize/index/__init__.py +3 -0
  23. pyoxidize-0.2.0/oxidize/index/staging.py +73 -0
  24. pyoxidize-0.2.0/oxidize/merge/__init__.py +5 -0
  25. pyoxidize-0.2.0/oxidize/merge/structured.py +55 -0
  26. pyoxidize-0.2.0/oxidize/notebook/__init__.py +5 -0
  27. pyoxidize-0.2.0/oxidize/notebook/differ.py +100 -0
  28. pyoxidize-0.2.0/oxidize/objects/__init__.py +12 -0
  29. pyoxidize-0.2.0/oxidize/objects/types.py +164 -0
  30. pyoxidize-0.2.0/oxidize/provenance/__init__.py +5 -0
  31. pyoxidize-0.2.0/oxidize/provenance/agent.py +84 -0
  32. pyoxidize-0.2.0/oxidize/security/__init__.py +5 -0
  33. pyoxidize-0.2.0/oxidize/security/scanner.py +65 -0
  34. pyoxidize-0.2.0/oxidize/semantic/__init__.py +14 -0
  35. pyoxidize-0.2.0/oxidize/semantic/differ.py +89 -0
  36. pyoxidize-0.2.0/oxidize/semantic/entities.py +122 -0
  37. pyoxidize-0.2.0/oxidize/storage/__init__.py +4 -0
  38. pyoxidize-0.2.0/oxidize/storage/backend.py +51 -0
  39. pyoxidize-0.2.0/oxidize/storage/database.py +45 -0
  40. pyoxidize-0.2.0/oxidize/undo/__init__.py +5 -0
  41. pyoxidize-0.2.0/oxidize/undo/journal.py +79 -0
  42. pyoxidize-0.2.0/oxidize/undo/reverser.py +93 -0
  43. pyoxidize-0.2.0/pyoxidize.egg-info/PKG-INFO +118 -0
  44. pyoxidize-0.2.0/pyoxidize.egg-info/SOURCES.txt +59 -0
  45. pyoxidize-0.2.0/pyoxidize.egg-info/dependency_links.txt +1 -0
  46. pyoxidize-0.2.0/pyoxidize.egg-info/entry_points.txt +3 -0
  47. pyoxidize-0.2.0/pyoxidize.egg-info/requires.txt +17 -0
  48. pyoxidize-0.2.0/pyoxidize.egg-info/top_level.txt +1 -0
  49. pyoxidize-0.2.0/pyproject.toml +53 -0
  50. pyoxidize-0.2.0/setup.cfg +4 -0
  51. pyoxidize-0.2.0/tests/test_diff.py +28 -0
  52. pyoxidize-0.2.0/tests/test_index.py +35 -0
  53. pyoxidize-0.2.0/tests/test_merge.py +70 -0
  54. pyoxidize-0.2.0/tests/test_notebook.py +106 -0
  55. pyoxidize-0.2.0/tests/test_objects.py +48 -0
  56. pyoxidize-0.2.0/tests/test_provenance.py +42 -0
  57. pyoxidize-0.2.0/tests/test_repository.py +33 -0
  58. pyoxidize-0.2.0/tests/test_scanner.py +65 -0
  59. pyoxidize-0.2.0/tests/test_semantic.py +73 -0
  60. pyoxidize-0.2.0/tests/test_storage.py +38 -0
  61. pyoxidize-0.2.0/tests/test_undo.py +64 -0
@@ -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
+ [![ci](https://github.com/siddhant-bayas/oxidize/actions/workflows/ci.yml/badge.svg)](https://github.com/siddhant-bayas/oxidize/actions/workflows/ci.yml)
28
+ [![pypi](https://img.shields.io/pypi/v/oxidize)](https://pypi.org/project/oxidize/)
29
+ [![python](https://img.shields.io/pypi/pyversions/oxidize)](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
+ [![ci](https://github.com/siddhant-bayas/oxidize/actions/workflows/ci.yml/badge.svg)](https://github.com/siddhant-bayas/oxidize/actions/workflows/ci.yml)
6
+ [![pypi](https://img.shields.io/pypi/v/oxidize)](https://pypi.org/project/oxidize/)
7
+ [![python](https://img.shields.io/pypi/pyversions/oxidize)](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"
@@ -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}[/]")