simple-vcs 1.0.0__py3-none-any.whl → 1.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.
- simple_vcs/__init__.py +10 -10
- simple_vcs/cli.py +145 -53
- simple_vcs/core.py +569 -276
- simple_vcs-1.2.0.dist-info/METADATA +352 -0
- simple_vcs-1.2.0.dist-info/RECORD +10 -0
- {simple_vcs-1.0.0.dist-info → simple_vcs-1.2.0.dist-info}/WHEEL +1 -1
- simple_vcs-1.0.0.dist-info/METADATA +0 -278
- simple_vcs-1.0.0.dist-info/RECORD +0 -10
- {simple_vcs-1.0.0.dist-info → simple_vcs-1.2.0.dist-info}/entry_points.txt +0 -0
- {simple_vcs-1.0.0.dist-info → simple_vcs-1.2.0.dist-info}/licenses/LICENSE +0 -0
- {simple_vcs-1.0.0.dist-info → simple_vcs-1.2.0.dist-info}/top_level.txt +0 -0
simple_vcs/core.py
CHANGED
|
@@ -1,276 +1,569 @@
|
|
|
1
|
-
import os
|
|
2
|
-
import json
|
|
3
|
-
import hashlib
|
|
4
|
-
import shutil
|
|
5
|
-
import time
|
|
6
|
-
|
|
7
|
-
from
|
|
8
|
-
from
|
|
9
|
-
|
|
10
|
-
|
|
11
|
-
|
|
12
|
-
|
|
13
|
-
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
self.svcs_dir
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
"
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
"
|
|
93
|
-
"
|
|
94
|
-
"
|
|
95
|
-
}
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
|
|
99
|
-
|
|
100
|
-
|
|
101
|
-
|
|
102
|
-
|
|
103
|
-
|
|
104
|
-
|
|
105
|
-
|
|
106
|
-
|
|
107
|
-
|
|
108
|
-
|
|
109
|
-
|
|
110
|
-
|
|
111
|
-
|
|
112
|
-
|
|
113
|
-
|
|
114
|
-
|
|
115
|
-
|
|
116
|
-
|
|
117
|
-
|
|
118
|
-
|
|
119
|
-
|
|
120
|
-
|
|
121
|
-
|
|
122
|
-
|
|
123
|
-
|
|
124
|
-
|
|
125
|
-
|
|
126
|
-
|
|
127
|
-
|
|
128
|
-
|
|
129
|
-
|
|
130
|
-
|
|
131
|
-
|
|
132
|
-
|
|
133
|
-
|
|
134
|
-
|
|
135
|
-
|
|
136
|
-
|
|
137
|
-
|
|
138
|
-
|
|
139
|
-
|
|
140
|
-
|
|
141
|
-
|
|
142
|
-
|
|
143
|
-
|
|
144
|
-
|
|
145
|
-
|
|
146
|
-
|
|
147
|
-
|
|
148
|
-
|
|
149
|
-
|
|
150
|
-
|
|
151
|
-
|
|
152
|
-
|
|
153
|
-
|
|
154
|
-
|
|
155
|
-
|
|
156
|
-
|
|
157
|
-
|
|
158
|
-
|
|
159
|
-
|
|
160
|
-
|
|
161
|
-
|
|
162
|
-
|
|
163
|
-
|
|
164
|
-
if
|
|
165
|
-
|
|
166
|
-
|
|
167
|
-
|
|
168
|
-
|
|
169
|
-
|
|
170
|
-
|
|
171
|
-
|
|
172
|
-
|
|
173
|
-
|
|
174
|
-
|
|
175
|
-
|
|
176
|
-
|
|
177
|
-
|
|
178
|
-
|
|
179
|
-
|
|
180
|
-
|
|
181
|
-
|
|
182
|
-
|
|
183
|
-
|
|
184
|
-
|
|
185
|
-
|
|
186
|
-
|
|
187
|
-
|
|
188
|
-
|
|
189
|
-
|
|
190
|
-
|
|
191
|
-
|
|
192
|
-
|
|
193
|
-
|
|
194
|
-
|
|
195
|
-
|
|
196
|
-
|
|
197
|
-
|
|
198
|
-
|
|
199
|
-
|
|
200
|
-
|
|
201
|
-
|
|
202
|
-
|
|
203
|
-
|
|
204
|
-
|
|
205
|
-
|
|
206
|
-
|
|
207
|
-
|
|
208
|
-
|
|
209
|
-
|
|
210
|
-
|
|
211
|
-
|
|
212
|
-
|
|
213
|
-
|
|
214
|
-
|
|
215
|
-
|
|
216
|
-
|
|
217
|
-
|
|
218
|
-
|
|
219
|
-
|
|
220
|
-
|
|
221
|
-
|
|
222
|
-
|
|
223
|
-
|
|
224
|
-
|
|
225
|
-
print("
|
|
226
|
-
|
|
227
|
-
|
|
228
|
-
|
|
229
|
-
|
|
230
|
-
|
|
231
|
-
|
|
232
|
-
|
|
233
|
-
|
|
234
|
-
|
|
235
|
-
|
|
236
|
-
|
|
237
|
-
|
|
238
|
-
|
|
239
|
-
|
|
240
|
-
|
|
241
|
-
|
|
242
|
-
|
|
243
|
-
|
|
244
|
-
|
|
245
|
-
|
|
246
|
-
|
|
247
|
-
|
|
248
|
-
|
|
249
|
-
|
|
250
|
-
|
|
251
|
-
|
|
252
|
-
|
|
253
|
-
|
|
254
|
-
""
|
|
255
|
-
|
|
256
|
-
|
|
257
|
-
|
|
258
|
-
|
|
259
|
-
|
|
260
|
-
|
|
261
|
-
|
|
262
|
-
|
|
263
|
-
|
|
264
|
-
|
|
265
|
-
|
|
266
|
-
|
|
267
|
-
|
|
268
|
-
|
|
269
|
-
|
|
270
|
-
|
|
271
|
-
|
|
272
|
-
|
|
273
|
-
|
|
274
|
-
|
|
275
|
-
|
|
276
|
-
|
|
1
|
+
import os
|
|
2
|
+
import json
|
|
3
|
+
import hashlib
|
|
4
|
+
import shutil
|
|
5
|
+
import time
|
|
6
|
+
import sys
|
|
7
|
+
from datetime import datetime
|
|
8
|
+
from pathlib import Path
|
|
9
|
+
from typing import Dict, List, Optional, Tuple
|
|
10
|
+
import zipfile
|
|
11
|
+
from rich.console import Console
|
|
12
|
+
from rich.table import Table
|
|
13
|
+
from rich.panel import Panel
|
|
14
|
+
from rich.progress import Progress, SpinnerColumn, TextColumn, BarColumn
|
|
15
|
+
from rich.syntax import Syntax
|
|
16
|
+
from rich import box
|
|
17
|
+
from rich.text import Text
|
|
18
|
+
|
|
19
|
+
class SimpleVCS:
|
|
20
|
+
"""Simple Version Control System core functionality"""
|
|
21
|
+
|
|
22
|
+
def __init__(self, repo_path: str = "."):
|
|
23
|
+
self.repo_path = Path(repo_path).resolve()
|
|
24
|
+
self.svcs_dir = self.repo_path / ".svcs"
|
|
25
|
+
self.objects_dir = self.svcs_dir / "objects"
|
|
26
|
+
self.commits_file = self.svcs_dir / "commits.json"
|
|
27
|
+
self.staging_file = self.svcs_dir / "staging.json"
|
|
28
|
+
self.head_file = self.svcs_dir / "HEAD"
|
|
29
|
+
# Force UTF-8 output and disable emoji on Windows
|
|
30
|
+
self.is_windows = sys.platform == "win32"
|
|
31
|
+
self.console = Console(force_terminal=True, legacy_windows=False)
|
|
32
|
+
|
|
33
|
+
def init_repo(self) -> bool:
|
|
34
|
+
"""Initialize a new repository"""
|
|
35
|
+
if self.svcs_dir.exists():
|
|
36
|
+
self.console.print(f"[yellow]WARNING: Repository already exists at[/yellow] [cyan]{self.repo_path}[/cyan]")
|
|
37
|
+
return False
|
|
38
|
+
|
|
39
|
+
self.console.print("[cyan]Initializing repository...[/cyan]")
|
|
40
|
+
|
|
41
|
+
# Create directory structure
|
|
42
|
+
self.svcs_dir.mkdir()
|
|
43
|
+
self.objects_dir.mkdir()
|
|
44
|
+
|
|
45
|
+
# Initialize files
|
|
46
|
+
self._write_json(self.commits_file, [])
|
|
47
|
+
self._write_json(self.staging_file, {})
|
|
48
|
+
self.head_file.write_text("0") # Start with commit 0
|
|
49
|
+
|
|
50
|
+
panel = Panel(
|
|
51
|
+
f"[green]SUCCESS[/green] Repository initialized at [cyan]{self.repo_path}[/cyan]\n\n"
|
|
52
|
+
"[dim]Structure created:[/dim]\n"
|
|
53
|
+
" - .svcs/objects/ - File storage\n"
|
|
54
|
+
" - .svcs/commits.json - Commit history\n"
|
|
55
|
+
" - .svcs/staging.json - Staged files\n"
|
|
56
|
+
" - .svcs/HEAD - Current commit",
|
|
57
|
+
title="[bold green]SimpleVCS Repository[/bold green]",
|
|
58
|
+
border_style="green",
|
|
59
|
+
box=box.ROUNDED
|
|
60
|
+
)
|
|
61
|
+
self.console.print(panel)
|
|
62
|
+
return True
|
|
63
|
+
|
|
64
|
+
def add_file(self, file_path: str) -> bool:
|
|
65
|
+
"""Add a file to staging area"""
|
|
66
|
+
if not self._check_repo():
|
|
67
|
+
return False
|
|
68
|
+
|
|
69
|
+
file_path = Path(file_path).resolve() # Convert to absolute path
|
|
70
|
+
if not file_path.exists():
|
|
71
|
+
self.console.print(f"[red]ERROR: File not found:[/red] [yellow]{file_path}[/yellow]")
|
|
72
|
+
return False
|
|
73
|
+
|
|
74
|
+
if not file_path.is_file():
|
|
75
|
+
self.console.print(f"[red]ERROR: Not a file:[/red] [yellow]{file_path}[/yellow]")
|
|
76
|
+
return False
|
|
77
|
+
|
|
78
|
+
# Check if file is within repository
|
|
79
|
+
try:
|
|
80
|
+
relative_path = file_path.relative_to(self.repo_path)
|
|
81
|
+
except ValueError:
|
|
82
|
+
self.console.print(f"[red]ERROR: File not in repository:[/red] [yellow]{file_path}[/yellow]")
|
|
83
|
+
return False
|
|
84
|
+
|
|
85
|
+
# Calculate file hash and store
|
|
86
|
+
file_hash = self._calculate_file_hash(file_path)
|
|
87
|
+
self._store_object(file_hash, file_path.read_bytes())
|
|
88
|
+
|
|
89
|
+
# Add to staging
|
|
90
|
+
staging = self._read_json(self.staging_file)
|
|
91
|
+
staging[str(relative_path)] = {
|
|
92
|
+
"hash": file_hash,
|
|
93
|
+
"size": file_path.stat().st_size,
|
|
94
|
+
"modified": file_path.stat().st_mtime
|
|
95
|
+
}
|
|
96
|
+
self._write_json(self.staging_file, staging)
|
|
97
|
+
|
|
98
|
+
# Format file size
|
|
99
|
+
size_kb = file_path.stat().st_size / 1024
|
|
100
|
+
size_str = f"{size_kb:.1f}KB" if size_kb < 1024 else f"{size_kb/1024:.1f}MB"
|
|
101
|
+
|
|
102
|
+
self.console.print(f"[green]Added:[/green] [cyan]{relative_path}[/cyan] [dim]({size_str})[/dim]")
|
|
103
|
+
return True
|
|
104
|
+
|
|
105
|
+
def commit(self, message: Optional[str] = None) -> bool:
|
|
106
|
+
"""Commit staged changes"""
|
|
107
|
+
if not self._check_repo():
|
|
108
|
+
return False
|
|
109
|
+
|
|
110
|
+
staging = self._read_json(self.staging_file)
|
|
111
|
+
if not staging:
|
|
112
|
+
self.console.print("[yellow]WARNING: No changes staged for commit[/yellow]")
|
|
113
|
+
self.console.print("[dim]Tip: Use 'svcs add <file>' to stage files[/dim]")
|
|
114
|
+
return False
|
|
115
|
+
|
|
116
|
+
# Create commit object
|
|
117
|
+
commit = {
|
|
118
|
+
"id": len(self._read_json(self.commits_file)) + 1,
|
|
119
|
+
"message": message or f"Commit at {datetime.now().strftime('%Y-%m-%d %H:%M:%S')}",
|
|
120
|
+
"timestamp": time.time(),
|
|
121
|
+
"files": staging.copy(),
|
|
122
|
+
"parent": self._get_current_commit_id()
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
# Save commit
|
|
126
|
+
commits = self._read_json(self.commits_file)
|
|
127
|
+
commits.append(commit)
|
|
128
|
+
self._write_json(self.commits_file, commits)
|
|
129
|
+
|
|
130
|
+
# Update HEAD
|
|
131
|
+
self.head_file.write_text(str(commit["id"]))
|
|
132
|
+
|
|
133
|
+
# Clear staging
|
|
134
|
+
self._write_json(self.staging_file, {})
|
|
135
|
+
|
|
136
|
+
# Create summary panel
|
|
137
|
+
files_list = "\n".join([f" - [cyan]{f}[/cyan]" for f in list(staging.keys())[:5]])
|
|
138
|
+
if len(staging) > 5:
|
|
139
|
+
files_list += f"\n [dim]... and {len(staging) - 5} more file(s)[/dim]"
|
|
140
|
+
|
|
141
|
+
panel = Panel(
|
|
142
|
+
f"[bold green]Commit #{commit['id']}[/bold green]\n\n"
|
|
143
|
+
f"[bold]Message:[/bold] {commit['message']}\n"
|
|
144
|
+
f"[bold]Files:[/bold] {len(staging)} file(s)\n\n"
|
|
145
|
+
f"{files_list}",
|
|
146
|
+
title="[bold green]Commit Successful[/bold green]",
|
|
147
|
+
border_style="green",
|
|
148
|
+
box=box.ROUNDED
|
|
149
|
+
)
|
|
150
|
+
self.console.print(panel)
|
|
151
|
+
return True
|
|
152
|
+
|
|
153
|
+
def show_diff(self, commit_id1: Optional[int] = None, commit_id2: Optional[int] = None) -> bool:
|
|
154
|
+
"""Show differences between commits"""
|
|
155
|
+
if not self._check_repo():
|
|
156
|
+
return False
|
|
157
|
+
|
|
158
|
+
commits = self._read_json(self.commits_file)
|
|
159
|
+
if not commits:
|
|
160
|
+
self.console.print("[yellow]WARNING: No commits found[/yellow]")
|
|
161
|
+
return False
|
|
162
|
+
|
|
163
|
+
# Default to comparing last two commits
|
|
164
|
+
if commit_id1 is None and commit_id2 is None:
|
|
165
|
+
if len(commits) < 2:
|
|
166
|
+
self.console.print("[yellow]WARNING: Need at least 2 commits to show diff[/yellow]")
|
|
167
|
+
return False
|
|
168
|
+
commit1 = commits[-2]
|
|
169
|
+
commit2 = commits[-1]
|
|
170
|
+
else:
|
|
171
|
+
commit1 = self._get_commit_by_id(commit_id1 or (len(commits) - 1))
|
|
172
|
+
commit2 = self._get_commit_by_id(commit_id2 or len(commits))
|
|
173
|
+
|
|
174
|
+
if not commit1 or not commit2:
|
|
175
|
+
self.console.print("[red]ERROR: Invalid commit IDs[/red]")
|
|
176
|
+
return False
|
|
177
|
+
|
|
178
|
+
# Create comparison table
|
|
179
|
+
table = Table(
|
|
180
|
+
title=f"[bold]Differences: Commit #{commit1['id']} to Commit #{commit2['id']}[/bold]",
|
|
181
|
+
box=box.ROUNDED,
|
|
182
|
+
show_header=True,
|
|
183
|
+
header_style="bold cyan"
|
|
184
|
+
)
|
|
185
|
+
table.add_column("Status", style="bold", width=10)
|
|
186
|
+
table.add_column("File", style="cyan")
|
|
187
|
+
table.add_column("Details", style="dim")
|
|
188
|
+
|
|
189
|
+
files1 = set(commit1["files"].keys())
|
|
190
|
+
files2 = set(commit2["files"].keys())
|
|
191
|
+
|
|
192
|
+
has_changes = False
|
|
193
|
+
|
|
194
|
+
# New files
|
|
195
|
+
new_files = sorted(files2 - files1)
|
|
196
|
+
for file in new_files:
|
|
197
|
+
size = commit2["files"][file]["size"]
|
|
198
|
+
size_str = f"{size/1024:.1f}KB" if size < 1024*1024 else f"{size/(1024*1024):.1f}MB"
|
|
199
|
+
table.add_row("[green]+ Added[/green]", file, f"Size: {size_str}")
|
|
200
|
+
has_changes = True
|
|
201
|
+
|
|
202
|
+
# Deleted files
|
|
203
|
+
deleted_files = sorted(files1 - files2)
|
|
204
|
+
for file in deleted_files:
|
|
205
|
+
table.add_row("[red]- Deleted[/red]", file, "")
|
|
206
|
+
has_changes = True
|
|
207
|
+
|
|
208
|
+
# Modified files
|
|
209
|
+
common_files = files1 & files2
|
|
210
|
+
modified_files = []
|
|
211
|
+
for file in sorted(common_files):
|
|
212
|
+
if commit1["files"][file]["hash"] != commit2["files"][file]["hash"]:
|
|
213
|
+
size1 = commit1["files"][file]["size"]
|
|
214
|
+
size2 = commit2["files"][file]["size"]
|
|
215
|
+
diff = size2 - size1
|
|
216
|
+
diff_str = f"+{diff/1024:.1f}KB" if diff > 0 else f"{diff/1024:.1f}KB"
|
|
217
|
+
table.add_row("[yellow]M Modified[/yellow]", file, f"Size change: {diff_str}")
|
|
218
|
+
modified_files.append(file)
|
|
219
|
+
has_changes = True
|
|
220
|
+
|
|
221
|
+
if not has_changes:
|
|
222
|
+
self.console.print("[dim]No differences found between commits[/dim]")
|
|
223
|
+
else:
|
|
224
|
+
self.console.print(table)
|
|
225
|
+
self.console.print(f"\n[dim]Summary: [green]{len(new_files)} added[/green], "
|
|
226
|
+
f"[yellow]{len(modified_files)} modified[/yellow], "
|
|
227
|
+
f"[red]{len(deleted_files)} deleted[/red][/dim]")
|
|
228
|
+
|
|
229
|
+
return True
|
|
230
|
+
|
|
231
|
+
def show_log(self, limit: Optional[int] = None) -> bool:
|
|
232
|
+
"""Show commit history"""
|
|
233
|
+
if not self._check_repo():
|
|
234
|
+
return False
|
|
235
|
+
|
|
236
|
+
commits = self._read_json(self.commits_file)
|
|
237
|
+
if not commits:
|
|
238
|
+
self.console.print("[yellow]WARNING: No commits found[/yellow]")
|
|
239
|
+
self.console.print("[dim]Tip: Use 'svcs commit -m \"message\"' to create your first commit[/dim]")
|
|
240
|
+
return False
|
|
241
|
+
|
|
242
|
+
commits_to_show = commits[-limit:] if limit else commits
|
|
243
|
+
commits_to_show.reverse() # Show newest first
|
|
244
|
+
|
|
245
|
+
# Create commits table
|
|
246
|
+
table = Table(
|
|
247
|
+
title="[bold cyan]Commit History[/bold cyan]",
|
|
248
|
+
box=box.ROUNDED,
|
|
249
|
+
show_header=True,
|
|
250
|
+
header_style="bold magenta"
|
|
251
|
+
)
|
|
252
|
+
table.add_column("ID", justify="center", style="cyan", width=6)
|
|
253
|
+
table.add_column("Date", style="green", width=19)
|
|
254
|
+
table.add_column("Message", style="white")
|
|
255
|
+
table.add_column("Files", justify="center", style="yellow", width=7)
|
|
256
|
+
table.add_column("Parent", justify="center", style="dim", width=7)
|
|
257
|
+
|
|
258
|
+
current_commit_id = self._get_current_commit_id()
|
|
259
|
+
|
|
260
|
+
for commit in commits_to_show:
|
|
261
|
+
commit_id = str(commit['id'])
|
|
262
|
+
if commit['id'] == current_commit_id:
|
|
263
|
+
commit_id = f"* {commit_id}" # Mark current commit
|
|
264
|
+
|
|
265
|
+
date_str = datetime.fromtimestamp(commit['timestamp']).strftime('%Y-%m-%d %H:%M:%S')
|
|
266
|
+
message = commit['message']
|
|
267
|
+
if len(message) > 50:
|
|
268
|
+
message = message[:47] + "..."
|
|
269
|
+
files_count = str(len(commit['files']))
|
|
270
|
+
parent = str(commit.get('parent', '-'))
|
|
271
|
+
|
|
272
|
+
# Style current commit differently
|
|
273
|
+
if commit['id'] == current_commit_id:
|
|
274
|
+
table.add_row(
|
|
275
|
+
f"[bold cyan]{commit_id}[/bold cyan]",
|
|
276
|
+
date_str,
|
|
277
|
+
f"[bold]{message}[/bold]",
|
|
278
|
+
files_count,
|
|
279
|
+
parent
|
|
280
|
+
)
|
|
281
|
+
else:
|
|
282
|
+
table.add_row(commit_id, date_str, message, files_count, parent)
|
|
283
|
+
|
|
284
|
+
self.console.print(table)
|
|
285
|
+
|
|
286
|
+
if limit and len(commits) > limit:
|
|
287
|
+
self.console.print(f"\n[dim]Showing last {limit} of {len(commits)} commits[/dim]")
|
|
288
|
+
|
|
289
|
+
return True
|
|
290
|
+
|
|
291
|
+
def status(self) -> bool:
|
|
292
|
+
"""Show repository status"""
|
|
293
|
+
if not self._check_repo():
|
|
294
|
+
return False
|
|
295
|
+
|
|
296
|
+
staging = self._read_json(self.staging_file)
|
|
297
|
+
current_commit = self._get_current_commit()
|
|
298
|
+
total_commits = len(self._read_json(self.commits_file))
|
|
299
|
+
|
|
300
|
+
# Create status panel
|
|
301
|
+
status_text = f"[bold]Repository:[/bold] [cyan]{self.repo_path.name}[/cyan]\n"
|
|
302
|
+
status_text += f"[bold]Location:[/bold] [dim]{self.repo_path}[/dim]\n"
|
|
303
|
+
status_text += f"[bold]Current Commit:[/bold] [green]#{current_commit['id']}[/green] " if current_commit else "[bold]Current Commit:[/bold] [yellow]None (no commits yet)[/yellow]\n"
|
|
304
|
+
if current_commit:
|
|
305
|
+
status_text += f"[dim]({current_commit['message']})[/dim]\n"
|
|
306
|
+
status_text += f"[bold]Total Commits:[/bold] {total_commits}"
|
|
307
|
+
|
|
308
|
+
panel = Panel(
|
|
309
|
+
status_text,
|
|
310
|
+
title="[bold cyan]Repository Status[/bold cyan]",
|
|
311
|
+
border_style="cyan",
|
|
312
|
+
box=box.ROUNDED
|
|
313
|
+
)
|
|
314
|
+
self.console.print(panel)
|
|
315
|
+
|
|
316
|
+
# Show staged files
|
|
317
|
+
if staging:
|
|
318
|
+
table = Table(
|
|
319
|
+
title="[bold yellow]Staged Files[/bold yellow]",
|
|
320
|
+
box=box.ROUNDED,
|
|
321
|
+
show_header=True,
|
|
322
|
+
header_style="bold yellow"
|
|
323
|
+
)
|
|
324
|
+
table.add_column("File", style="cyan")
|
|
325
|
+
table.add_column("Size", justify="right", style="green")
|
|
326
|
+
table.add_column("Hash", style="dim", width=16)
|
|
327
|
+
|
|
328
|
+
for file, info in staging.items():
|
|
329
|
+
size = info['size']
|
|
330
|
+
size_str = f"{size/1024:.1f}KB" if size < 1024*1024 else f"{size/(1024*1024):.1f}MB"
|
|
331
|
+
hash_short = info['hash'][:14] + "..."
|
|
332
|
+
table.add_row(file, size_str, hash_short)
|
|
333
|
+
|
|
334
|
+
self.console.print("\n", table)
|
|
335
|
+
self.console.print(f"[dim]Ready to commit {len(staging)} file(s)[/dim]")
|
|
336
|
+
else:
|
|
337
|
+
self.console.print("\n[yellow]No files staged[/yellow]")
|
|
338
|
+
self.console.print("[dim]Use 'svcs add <file>' to stage files for commit[/dim]")
|
|
339
|
+
|
|
340
|
+
return True
|
|
341
|
+
|
|
342
|
+
# Helper methods
|
|
343
|
+
def _check_repo(self) -> bool:
|
|
344
|
+
"""Check if repository is initialized"""
|
|
345
|
+
if not self.svcs_dir.exists():
|
|
346
|
+
self.console.print("[red]ERROR: Not a SimpleVCS repository[/red]")
|
|
347
|
+
self.console.print("[dim]Tip: Run 'svcs init' to initialize a repository[/dim]")
|
|
348
|
+
return False
|
|
349
|
+
return True
|
|
350
|
+
|
|
351
|
+
def _calculate_file_hash(self, file_path: Path) -> str:
|
|
352
|
+
"""Calculate SHA-256 hash of file"""
|
|
353
|
+
hasher = hashlib.sha256()
|
|
354
|
+
with open(file_path, 'rb') as f:
|
|
355
|
+
for chunk in iter(lambda: f.read(4096), b""):
|
|
356
|
+
hasher.update(chunk)
|
|
357
|
+
return hasher.hexdigest()
|
|
358
|
+
|
|
359
|
+
def _store_object(self, obj_hash: str, content: bytes):
|
|
360
|
+
"""Store object in objects directory"""
|
|
361
|
+
obj_path = self.objects_dir / obj_hash
|
|
362
|
+
if not obj_path.exists():
|
|
363
|
+
obj_path.write_bytes(content)
|
|
364
|
+
|
|
365
|
+
def _read_json(self, file_path: Path) -> Dict:
|
|
366
|
+
"""Read JSON file"""
|
|
367
|
+
if not file_path.exists():
|
|
368
|
+
return {}
|
|
369
|
+
return json.loads(file_path.read_text())
|
|
370
|
+
|
|
371
|
+
def _write_json(self, file_path: Path, data: Dict):
|
|
372
|
+
"""Write JSON file"""
|
|
373
|
+
file_path.write_text(json.dumps(data, indent=2))
|
|
374
|
+
|
|
375
|
+
def _get_current_commit_id(self) -> Optional[int]:
|
|
376
|
+
"""Get current commit ID"""
|
|
377
|
+
if not self.head_file.exists():
|
|
378
|
+
return None
|
|
379
|
+
try:
|
|
380
|
+
commit_id = int(self.head_file.read_text().strip())
|
|
381
|
+
return commit_id if commit_id > 0 else None
|
|
382
|
+
except:
|
|
383
|
+
return None
|
|
384
|
+
|
|
385
|
+
def _get_current_commit(self) -> Optional[Dict]:
|
|
386
|
+
"""Get current commit object"""
|
|
387
|
+
commit_id = self._get_current_commit_id()
|
|
388
|
+
if not commit_id:
|
|
389
|
+
return None
|
|
390
|
+
return self._get_commit_by_id(commit_id)
|
|
391
|
+
|
|
392
|
+
def _get_commit_by_id(self, commit_id: int) -> Optional[Dict]:
|
|
393
|
+
"""Get commit by ID"""
|
|
394
|
+
commits = self._read_json(self.commits_file)
|
|
395
|
+
for commit in commits:
|
|
396
|
+
if commit["id"] == commit_id:
|
|
397
|
+
return commit
|
|
398
|
+
return None
|
|
399
|
+
|
|
400
|
+
def quick_revert(self, commit_id: int) -> bool:
|
|
401
|
+
"""Quickly revert to a specific commit"""
|
|
402
|
+
if not self._check_repo():
|
|
403
|
+
return False
|
|
404
|
+
|
|
405
|
+
commit = self._get_commit_by_id(commit_id)
|
|
406
|
+
if not commit:
|
|
407
|
+
self.console.print(f"[red]ERROR: Commit #{commit_id} not found[/red]")
|
|
408
|
+
return False
|
|
409
|
+
|
|
410
|
+
self.console.print(f"[cyan]Reverting to commit #{commit_id}...[/cyan]")
|
|
411
|
+
|
|
412
|
+
# Restore files from the specified commit
|
|
413
|
+
for file_path, file_info in commit["files"].items():
|
|
414
|
+
target_path = self.repo_path / file_path
|
|
415
|
+
obj_path = self.objects_dir / file_info["hash"]
|
|
416
|
+
|
|
417
|
+
# Create parent directories if they don't exist
|
|
418
|
+
target_path.parent.mkdir(parents=True, exist_ok=True)
|
|
419
|
+
|
|
420
|
+
# Copy file from objects to target location
|
|
421
|
+
if obj_path.exists():
|
|
422
|
+
shutil.copy2(obj_path, target_path)
|
|
423
|
+
|
|
424
|
+
# Update HEAD to point to the reverted commit
|
|
425
|
+
self.head_file.write_text(str(commit_id))
|
|
426
|
+
|
|
427
|
+
panel = Panel(
|
|
428
|
+
f"[bold green]Successfully reverted to commit #{commit_id}[/bold green]\n\n"
|
|
429
|
+
f"[bold]Message:[/bold] {commit['message']}\n"
|
|
430
|
+
f"[bold]Files restored:[/bold] {len(commit['files'])}\n"
|
|
431
|
+
f"[bold]Date:[/bold] {datetime.fromtimestamp(commit['timestamp']).strftime('%Y-%m-%d %H:%M:%S')}",
|
|
432
|
+
title="[bold green]Revert Complete[/bold green]",
|
|
433
|
+
border_style="green",
|
|
434
|
+
box=box.ROUNDED
|
|
435
|
+
)
|
|
436
|
+
self.console.print(panel)
|
|
437
|
+
return True
|
|
438
|
+
|
|
439
|
+
def create_snapshot(self, name: str = None) -> bool:
|
|
440
|
+
"""Create a compressed snapshot of the current repository state"""
|
|
441
|
+
if not self._check_repo():
|
|
442
|
+
return False
|
|
443
|
+
|
|
444
|
+
snapshot_name = name or f"snapshot_{int(time.time())}"
|
|
445
|
+
snapshot_path = self.repo_path / f"{snapshot_name}.zip"
|
|
446
|
+
|
|
447
|
+
self.console.print("[cyan]Creating snapshot...[/cyan]")
|
|
448
|
+
|
|
449
|
+
# Count files first
|
|
450
|
+
file_count = 0
|
|
451
|
+
for root, dirs, files in os.walk(self.repo_path):
|
|
452
|
+
dirs[:] = [d for d in dirs if d != '.svcs']
|
|
453
|
+
file_count += len(files)
|
|
454
|
+
|
|
455
|
+
# Create a zip archive of all tracked files
|
|
456
|
+
with zipfile.ZipFile(snapshot_path, 'w', zipfile.ZIP_DEFLATED) as zipf:
|
|
457
|
+
for root, dirs, files in os.walk(self.repo_path):
|
|
458
|
+
# Skip .svcs directory
|
|
459
|
+
dirs[:] = [d for d in dirs if d != '.svcs']
|
|
460
|
+
|
|
461
|
+
for file in files:
|
|
462
|
+
file_path = Path(root) / file
|
|
463
|
+
if file_path != snapshot_path: # Don't include the snapshot itself
|
|
464
|
+
arc_path = file_path.relative_to(self.repo_path)
|
|
465
|
+
zipf.write(file_path, arc_path)
|
|
466
|
+
|
|
467
|
+
snapshot_size = snapshot_path.stat().st_size
|
|
468
|
+
size_str = f"{snapshot_size/1024:.1f}KB" if snapshot_size < 1024*1024 else f"{snapshot_size/(1024*1024):.1f}MB"
|
|
469
|
+
|
|
470
|
+
panel = Panel(
|
|
471
|
+
f"[bold green]Snapshot created successfully[/bold green]\n\n"
|
|
472
|
+
f"[bold]Name:[/bold] [cyan]{snapshot_name}.zip[/cyan]\n"
|
|
473
|
+
f"[bold]Location:[/bold] [dim]{snapshot_path}[/dim]\n"
|
|
474
|
+
f"[bold]Size:[/bold] {size_str}\n"
|
|
475
|
+
f"[bold]Files:[/bold] {file_count}",
|
|
476
|
+
title="[bold green]Snapshot Created[/bold green]",
|
|
477
|
+
border_style="green",
|
|
478
|
+
box=box.ROUNDED
|
|
479
|
+
)
|
|
480
|
+
self.console.print(panel)
|
|
481
|
+
return True
|
|
482
|
+
|
|
483
|
+
def restore_from_snapshot(self, snapshot_path: str) -> bool:
|
|
484
|
+
"""Restore repository from a snapshot"""
|
|
485
|
+
snapshot_path = Path(snapshot_path)
|
|
486
|
+
if not snapshot_path.exists():
|
|
487
|
+
self.console.print(f"[red]ERROR: Snapshot not found:[/red] [yellow]{snapshot_path}[/yellow]")
|
|
488
|
+
return False
|
|
489
|
+
|
|
490
|
+
self.console.print("[cyan]Restoring from snapshot...[/cyan]")
|
|
491
|
+
|
|
492
|
+
# Extract the zip archive
|
|
493
|
+
with zipfile.ZipFile(snapshot_path, 'r') as zipf:
|
|
494
|
+
file_list = zipf.namelist()
|
|
495
|
+
|
|
496
|
+
# Clear current files (but preserve .svcs directory)
|
|
497
|
+
for item in self.repo_path.iterdir():
|
|
498
|
+
if item.name != '.svcs':
|
|
499
|
+
if item.is_dir():
|
|
500
|
+
shutil.rmtree(item)
|
|
501
|
+
else:
|
|
502
|
+
item.unlink()
|
|
503
|
+
|
|
504
|
+
# Extract all files
|
|
505
|
+
zipf.extractall(self.repo_path)
|
|
506
|
+
|
|
507
|
+
panel = Panel(
|
|
508
|
+
f"[bold green]Repository restored successfully[/bold green]\n\n"
|
|
509
|
+
f"[bold]Snapshot:[/bold] [cyan]{snapshot_path.name}[/cyan]\n"
|
|
510
|
+
f"[bold]Files restored:[/bold] {len(file_list)}",
|
|
511
|
+
title="[bold green]Restore Complete[/bold green]",
|
|
512
|
+
border_style="green",
|
|
513
|
+
box=box.ROUNDED
|
|
514
|
+
)
|
|
515
|
+
self.console.print(panel)
|
|
516
|
+
return True
|
|
517
|
+
|
|
518
|
+
def compress_objects(self) -> bool:
|
|
519
|
+
"""Compress stored objects to save space"""
|
|
520
|
+
if not self._check_repo():
|
|
521
|
+
return False
|
|
522
|
+
|
|
523
|
+
original_size = sum(f.stat().st_size for f in self.objects_dir.glob('*') if f.is_file())
|
|
524
|
+
obj_files = [f for f in self.objects_dir.glob('*') if f.is_file() and f.stat().st_size > 1024]
|
|
525
|
+
|
|
526
|
+
if not obj_files:
|
|
527
|
+
self.console.print("[yellow]WARNING: No objects to compress (files are too small)[/yellow]")
|
|
528
|
+
return True
|
|
529
|
+
|
|
530
|
+
self.console.print("[cyan]Compressing objects...[/cyan]")
|
|
531
|
+
|
|
532
|
+
# For each object file, compress it if it's large enough to benefit
|
|
533
|
+
for obj_file in obj_files:
|
|
534
|
+
# Create a compressed version with .gz extension
|
|
535
|
+
compressed_path = obj_file.with_suffix(obj_file.suffix + '.gz')
|
|
536
|
+
with open(obj_file, 'rb') as f_in:
|
|
537
|
+
import gzip
|
|
538
|
+
with gzip.open(compressed_path, 'wb') as f_out:
|
|
539
|
+
f_out.writelines(f_in)
|
|
540
|
+
|
|
541
|
+
# Replace original with compressed version
|
|
542
|
+
obj_file.unlink()
|
|
543
|
+
# Decompress back to original name for compatibility
|
|
544
|
+
with gzip.open(compressed_path, 'rb') as f_in:
|
|
545
|
+
with open(obj_file, 'wb') as f_out:
|
|
546
|
+
f_out.write(f_in.read())
|
|
547
|
+
compressed_path.unlink()
|
|
548
|
+
|
|
549
|
+
new_size = sum(f.stat().st_size for f in self.objects_dir.glob('*') if f.is_file())
|
|
550
|
+
saved_space = original_size - new_size
|
|
551
|
+
saved_percent = (saved_space / original_size * 100) if original_size > 0 else 0
|
|
552
|
+
|
|
553
|
+
# Format sizes
|
|
554
|
+
orig_str = f"{original_size/1024:.1f}KB" if original_size < 1024*1024 else f"{original_size/(1024*1024):.1f}MB"
|
|
555
|
+
new_str = f"{new_size/1024:.1f}KB" if new_size < 1024*1024 else f"{new_size/(1024*1024):.1f}MB"
|
|
556
|
+
saved_str = f"{saved_space/1024:.1f}KB" if saved_space < 1024*1024 else f"{saved_space/(1024*1024):.1f}MB"
|
|
557
|
+
|
|
558
|
+
panel = Panel(
|
|
559
|
+
f"[bold green]Compression completed successfully[/bold green]\n\n"
|
|
560
|
+
f"[bold]Original size:[/bold] {orig_str}\n"
|
|
561
|
+
f"[bold]New size:[/bold] {new_str}\n"
|
|
562
|
+
f"[bold]Space saved:[/bold] [green]{saved_str}[/green] [dim]({saved_percent:.1f}%)[/dim]\n"
|
|
563
|
+
f"[bold]Objects compressed:[/bold] {len(obj_files)}",
|
|
564
|
+
title="[bold green]Compression Complete[/bold green]",
|
|
565
|
+
border_style="green",
|
|
566
|
+
box=box.ROUNDED
|
|
567
|
+
)
|
|
568
|
+
self.console.print(panel)
|
|
569
|
+
return True
|