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/core.py CHANGED
@@ -1,276 +1,569 @@
1
- import os
2
- import json
3
- import hashlib
4
- import shutil
5
- import time
6
- from datetime import datetime
7
- from pathlib import Path
8
- from typing import Dict, List, Optional, Tuple
9
-
10
- class SimpleVCS:
11
- """Simple Version Control System core functionality"""
12
-
13
- def __init__(self, repo_path: str = "."):
14
- self.repo_path = Path(repo_path).resolve()
15
- self.svcs_dir = self.repo_path / ".svcs"
16
- self.objects_dir = self.svcs_dir / "objects"
17
- self.commits_file = self.svcs_dir / "commits.json"
18
- self.staging_file = self.svcs_dir / "staging.json"
19
- self.head_file = self.svcs_dir / "HEAD"
20
-
21
- def init_repo(self) -> bool:
22
- """Initialize a new repository"""
23
- if self.svcs_dir.exists():
24
- print(f"Repository already exists at {self.repo_path}")
25
- return False
26
-
27
- # Create directory structure
28
- self.svcs_dir.mkdir()
29
- self.objects_dir.mkdir()
30
-
31
- # Initialize files
32
- self._write_json(self.commits_file, [])
33
- self._write_json(self.staging_file, {})
34
- self.head_file.write_text("0") # Start with commit 0
35
-
36
- print(f"Initialized empty SimpleVCS repository at {self.repo_path}")
37
- return True
38
-
39
- def add_file(self, file_path: str) -> bool:
40
- """Add a file to staging area"""
41
- if not self._check_repo():
42
- return False
43
-
44
- file_path = Path(file_path).resolve() # Convert to absolute path
45
- if not file_path.exists():
46
- print(f"File {file_path} does not exist")
47
- return False
48
-
49
- if not file_path.is_file():
50
- print(f"{file_path} is not a file")
51
- return False
52
-
53
- # Check if file is within repository
54
- try:
55
- relative_path = file_path.relative_to(self.repo_path)
56
- except ValueError:
57
- print(f"File {file_path} is not within the repository")
58
- return False
59
-
60
- # Calculate file hash
61
- file_hash = self._calculate_file_hash(file_path)
62
-
63
- # Store file content in objects
64
- self._store_object(file_hash, file_path.read_bytes())
65
-
66
- # Add to staging
67
- staging = self._read_json(self.staging_file)
68
- staging[str(relative_path)] = {
69
- "hash": file_hash,
70
- "size": file_path.stat().st_size,
71
- "modified": file_path.stat().st_mtime
72
- }
73
- self._write_json(self.staging_file, staging)
74
-
75
- print(f"Added {file_path.name} to staging area")
76
- return True
77
-
78
- def commit(self, message: Optional[str] = None) -> bool:
79
- """Commit staged changes"""
80
- if not self._check_repo():
81
- return False
82
-
83
- staging = self._read_json(self.staging_file)
84
- if not staging:
85
- print("No changes to commit")
86
- return False
87
-
88
- # Create commit object
89
- commit = {
90
- "id": len(self._read_json(self.commits_file)) + 1,
91
- "message": message or f"Commit at {datetime.now().strftime('%Y-%m-%d %H:%M:%S')}",
92
- "timestamp": time.time(),
93
- "files": staging.copy(),
94
- "parent": self._get_current_commit_id()
95
- }
96
-
97
- # Save commit
98
- commits = self._read_json(self.commits_file)
99
- commits.append(commit)
100
- self._write_json(self.commits_file, commits)
101
-
102
- # Update HEAD
103
- self.head_file.write_text(str(commit["id"]))
104
-
105
- # Clear staging
106
- self._write_json(self.staging_file, {})
107
-
108
- print(f"Committed changes with ID: {commit['id']}")
109
- print(f"Message: {commit['message']}")
110
- return True
111
-
112
- def show_diff(self, commit_id1: Optional[int] = None, commit_id2: Optional[int] = None) -> bool:
113
- """Show differences between commits"""
114
- if not self._check_repo():
115
- return False
116
-
117
- commits = self._read_json(self.commits_file)
118
- if not commits:
119
- print("No commits found")
120
- return False
121
-
122
- # Default to comparing last two commits
123
- if commit_id1 is None and commit_id2 is None:
124
- if len(commits) < 2:
125
- print("Need at least 2 commits to show diff")
126
- return False
127
- commit1 = commits[-2]
128
- commit2 = commits[-1]
129
- else:
130
- commit1 = self._get_commit_by_id(commit_id1 or (len(commits) - 1))
131
- commit2 = self._get_commit_by_id(commit_id2 or len(commits))
132
-
133
- if not commit1 or not commit2:
134
- print("Invalid commit IDs")
135
- return False
136
-
137
- print(f"\nDifferences between commit {commit1['id']} and {commit2['id']}:")
138
- print("-" * 50)
139
-
140
- files1 = set(commit1["files"].keys())
141
- files2 = set(commit2["files"].keys())
142
-
143
- # New files
144
- new_files = files2 - files1
145
- if new_files:
146
- print("New files:")
147
- for file in new_files:
148
- print(f" + {file}")
149
-
150
- # Deleted files
151
- deleted_files = files1 - files2
152
- if deleted_files:
153
- print("Deleted files:")
154
- for file in deleted_files:
155
- print(f" - {file}")
156
-
157
- # Modified files
158
- common_files = files1 & files2
159
- modified_files = []
160
- for file in common_files:
161
- if commit1["files"][file]["hash"] != commit2["files"][file]["hash"]:
162
- modified_files.append(file)
163
-
164
- if modified_files:
165
- print("Modified files:")
166
- for file in modified_files:
167
- print(f" M {file}")
168
-
169
- if not new_files and not deleted_files and not modified_files:
170
- print("No differences found")
171
-
172
- return True
173
-
174
- def show_log(self, limit: Optional[int] = None) -> bool:
175
- """Show commit history"""
176
- if not self._check_repo():
177
- return False
178
-
179
- commits = self._read_json(self.commits_file)
180
- if not commits:
181
- print("No commits found")
182
- return False
183
-
184
- commits_to_show = commits[-limit:] if limit else commits
185
- commits_to_show.reverse() # Show newest first
186
-
187
- print("\nCommit History:")
188
- print("=" * 50)
189
-
190
- for commit in commits_to_show:
191
- print(f"Commit ID: {commit['id']}")
192
- print(f"Date: {datetime.fromtimestamp(commit['timestamp']).strftime('%Y-%m-%d %H:%M:%S')}")
193
- print(f"Message: {commit['message']}")
194
- print(f"Files: {len(commit['files'])} file(s)")
195
- if commit.get('parent'):
196
- print(f"Parent: {commit['parent']}")
197
- print("-" * 30)
198
-
199
- return True
200
-
201
- def status(self) -> bool:
202
- """Show repository status"""
203
- if not self._check_repo():
204
- return False
205
-
206
- staging = self._read_json(self.staging_file)
207
- current_commit = self._get_current_commit()
208
-
209
- print(f"\nRepository: {self.repo_path}")
210
- print(f"Current commit: {current_commit['id'] if current_commit else 'None'}")
211
-
212
- if staging:
213
- print("\nStaged files:")
214
- for file, info in staging.items():
215
- print(f" {file}")
216
- else:
217
- print("\nNo files staged")
218
-
219
- return True
220
-
221
- # Helper methods
222
- def _check_repo(self) -> bool:
223
- """Check if repository is initialized"""
224
- if not self.svcs_dir.exists():
225
- print("Not a SimpleVCS repository. Run 'svcs init' first.")
226
- return False
227
- return True
228
-
229
- def _calculate_file_hash(self, file_path: Path) -> str:
230
- """Calculate SHA-256 hash of file"""
231
- hasher = hashlib.sha256()
232
- with open(file_path, 'rb') as f:
233
- for chunk in iter(lambda: f.read(4096), b""):
234
- hasher.update(chunk)
235
- return hasher.hexdigest()
236
-
237
- def _store_object(self, obj_hash: str, content: bytes):
238
- """Store object in objects directory"""
239
- obj_path = self.objects_dir / obj_hash
240
- if not obj_path.exists():
241
- obj_path.write_bytes(content)
242
-
243
- def _read_json(self, file_path: Path) -> Dict:
244
- """Read JSON file"""
245
- if not file_path.exists():
246
- return {}
247
- return json.loads(file_path.read_text())
248
-
249
- def _write_json(self, file_path: Path, data: Dict):
250
- """Write JSON file"""
251
- file_path.write_text(json.dumps(data, indent=2))
252
-
253
- def _get_current_commit_id(self) -> Optional[int]:
254
- """Get current commit ID"""
255
- if not self.head_file.exists():
256
- return None
257
- try:
258
- commit_id = int(self.head_file.read_text().strip())
259
- return commit_id if commit_id > 0 else None
260
- except:
261
- return None
262
-
263
- def _get_current_commit(self) -> Optional[Dict]:
264
- """Get current commit object"""
265
- commit_id = self._get_current_commit_id()
266
- if not commit_id:
267
- return None
268
- return self._get_commit_by_id(commit_id)
269
-
270
- def _get_commit_by_id(self, commit_id: int) -> Optional[Dict]:
271
- """Get commit by ID"""
272
- commits = self._read_json(self.commits_file)
273
- for commit in commits:
274
- if commit["id"] == commit_id:
275
- return commit
276
- return None
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