slots-cli 0.1.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.
- slots_cli-0.1.0/PKG-INFO +18 -0
- slots_cli-0.1.0/README.md +186 -0
- slots_cli-0.1.0/setup.cfg +4 -0
- slots_cli-0.1.0/setup.py +22 -0
- slots_cli-0.1.0/slots/__init__.py +3 -0
- slots_cli-0.1.0/slots/__main__.py +66 -0
- slots_cli-0.1.0/slots/load.py +214 -0
- slots_cli-0.1.0/slots/save.py +108 -0
- slots_cli-0.1.0/slots/slots.py +311 -0
- slots_cli-0.1.0/slots/slots_mcp.py +82 -0
- slots_cli-0.1.0/slots/utils.py +178 -0
- slots_cli-0.1.0/slots_cli.egg-info/PKG-INFO +18 -0
- slots_cli-0.1.0/slots_cli.egg-info/SOURCES.txt +17 -0
- slots_cli-0.1.0/slots_cli.egg-info/dependency_links.txt +1 -0
- slots_cli-0.1.0/slots_cli.egg-info/entry_points.txt +3 -0
- slots_cli-0.1.0/slots_cli.egg-info/requires.txt +7 -0
- slots_cli-0.1.0/slots_cli.egg-info/top_level.txt +1 -0
- slots_cli-0.1.0/tests/test_slots_flows.py +234 -0
- slots_cli-0.1.0/tests/test_utils.py +90 -0
slots_cli-0.1.0/PKG-INFO
ADDED
|
@@ -0,0 +1,18 @@
|
|
|
1
|
+
Metadata-Version: 2.4
|
|
2
|
+
Name: slots-cli
|
|
3
|
+
Version: 0.1.0
|
|
4
|
+
Summary: Save slots for coding projects.
|
|
5
|
+
Home-page: https://slots.hohohocch.com
|
|
6
|
+
Author: hohohocch
|
|
7
|
+
Requires-Python: >=3.10
|
|
8
|
+
Requires-Dist: colorama
|
|
9
|
+
Provides-Extra: dev
|
|
10
|
+
Requires-Dist: pytest; extra == "dev"
|
|
11
|
+
Provides-Extra: mcp
|
|
12
|
+
Requires-Dist: mcp[cli]; extra == "mcp"
|
|
13
|
+
Dynamic: author
|
|
14
|
+
Dynamic: home-page
|
|
15
|
+
Dynamic: provides-extra
|
|
16
|
+
Dynamic: requires-dist
|
|
17
|
+
Dynamic: requires-python
|
|
18
|
+
Dynamic: summary
|
|
@@ -0,0 +1,186 @@
|
|
|
1
|
+

|
|
2
|
+
|
|
3
|
+
# Slots
|
|
4
|
+
|
|
5
|
+
Save slots for your local directory - Checkpoint your progress along the way with no commitment.
|
|
6
|
+
|
|
7
|
+
Slots is a small CLI for creating save slots inside a coding project. It is useful when you want to attempt a refactor, compare approaches, or let an AI coding agent make changes while keeping an easy restore point.
|
|
8
|
+
|
|
9
|
+
## Installation
|
|
10
|
+
|
|
11
|
+
```powershell
|
|
12
|
+
pip install slots-cli
|
|
13
|
+
```
|
|
14
|
+
|
|
15
|
+
For MCP support:
|
|
16
|
+
|
|
17
|
+
```powershell
|
|
18
|
+
pip install "slots-cli[mcp]"
|
|
19
|
+
```
|
|
20
|
+
|
|
21
|
+
## Quickstart
|
|
22
|
+
|
|
23
|
+
Create a save slot before making risky changes:
|
|
24
|
+
|
|
25
|
+
```powershell
|
|
26
|
+
slots init
|
|
27
|
+
slots save before-refactor
|
|
28
|
+
```
|
|
29
|
+
|
|
30
|
+
Make your edits, then check what changed:
|
|
31
|
+
|
|
32
|
+
```powershell
|
|
33
|
+
slots status
|
|
34
|
+
```
|
|
35
|
+
|
|
36
|
+
```diff
|
|
37
|
+
Difference from base state
|
|
38
|
+
3 changed: 1 added, 1 modified, 1 removed
|
|
39
|
+
|
|
40
|
+
Added (1)
|
|
41
|
+
+ new_feature.py
|
|
42
|
+
|
|
43
|
+
Modified (1)
|
|
44
|
+
! app.py
|
|
45
|
+
|
|
46
|
+
Removed (1)
|
|
47
|
+
- old_utils.py
|
|
48
|
+
```
|
|
49
|
+
|
|
50
|
+
Restore the save slot if you want to go back:
|
|
51
|
+
|
|
52
|
+
```powershell
|
|
53
|
+
slots load before-refactor
|
|
54
|
+
```
|
|
55
|
+
|
|
56
|
+
Undo the most recent load if you change your mind:
|
|
57
|
+
|
|
58
|
+
```powershell
|
|
59
|
+
slots revert
|
|
60
|
+
```
|
|
61
|
+
|
|
62
|
+
## Commands
|
|
63
|
+
|
|
64
|
+
### Initialize
|
|
65
|
+
|
|
66
|
+
```powershell
|
|
67
|
+
slots init
|
|
68
|
+
```
|
|
69
|
+
|
|
70
|
+
Creates `.slots/` and records the base state. All saves are tied to the same base state.
|
|
71
|
+
|
|
72
|
+
Refresh the base state:
|
|
73
|
+
|
|
74
|
+
```powershell
|
|
75
|
+
slots init --refresh
|
|
76
|
+
```
|
|
77
|
+
|
|
78
|
+
This clears existing save slots and aligns the base state with the current respository.
|
|
79
|
+
|
|
80
|
+
### Save
|
|
81
|
+
|
|
82
|
+
```powershell
|
|
83
|
+
slots save before-refactor
|
|
84
|
+
```
|
|
85
|
+
|
|
86
|
+
Creates a named save slot from the current project state.
|
|
87
|
+
|
|
88
|
+
### Status
|
|
89
|
+
|
|
90
|
+
```powershell
|
|
91
|
+
slots status
|
|
92
|
+
```
|
|
93
|
+
|
|
94
|
+
Shows differences between the current project and the base state.
|
|
95
|
+
|
|
96
|
+
```powershell
|
|
97
|
+
slots status --latest
|
|
98
|
+
```
|
|
99
|
+
|
|
100
|
+
Shows differences between the current project and the most recent save slot.
|
|
101
|
+
|
|
102
|
+
### List
|
|
103
|
+
|
|
104
|
+
```powershell
|
|
105
|
+
slots list
|
|
106
|
+
```
|
|
107
|
+
|
|
108
|
+
Lists save slots.
|
|
109
|
+
|
|
110
|
+
### Load
|
|
111
|
+
|
|
112
|
+
```powershell
|
|
113
|
+
slots load before-refactor
|
|
114
|
+
```
|
|
115
|
+
|
|
116
|
+
Loads added and modified files from a save slot.
|
|
117
|
+
|
|
118
|
+
```powershell
|
|
119
|
+
slots load before-refactor --remove-files
|
|
120
|
+
```
|
|
121
|
+
|
|
122
|
+
Also removes files that were deleted in the save slot.
|
|
123
|
+
|
|
124
|
+
### Revert
|
|
125
|
+
|
|
126
|
+
```powershell
|
|
127
|
+
slots revert
|
|
128
|
+
```
|
|
129
|
+
|
|
130
|
+
Reverts the most recent load using the automatic pre-load backup.
|
|
131
|
+
|
|
132
|
+
### Delete
|
|
133
|
+
|
|
134
|
+
```powershell
|
|
135
|
+
slots delete before-refactor
|
|
136
|
+
```
|
|
137
|
+
|
|
138
|
+
Deletes a save slot.
|
|
139
|
+
|
|
140
|
+
### Reset
|
|
141
|
+
|
|
142
|
+
```powershell
|
|
143
|
+
slots reset
|
|
144
|
+
```
|
|
145
|
+
|
|
146
|
+
Deletes all Slots data from the project.
|
|
147
|
+
|
|
148
|
+
## MCP Support
|
|
149
|
+
|
|
150
|
+
Slots includes a MCP server so AI coding tools can create and restore save slots.
|
|
151
|
+
|
|
152
|
+
Install with MCP support:
|
|
153
|
+
|
|
154
|
+
```powershell
|
|
155
|
+
pip install "slots-cli[mcp]"
|
|
156
|
+
```
|
|
157
|
+
|
|
158
|
+
Then run the MCP server:
|
|
159
|
+
|
|
160
|
+
```powershell
|
|
161
|
+
slots-mcp
|
|
162
|
+
```
|
|
163
|
+
|
|
164
|
+
### Common Installation commands:
|
|
165
|
+
|
|
166
|
+
**Codex**
|
|
167
|
+
|
|
168
|
+
```powershell
|
|
169
|
+
codex mcp add slots -- slots-mcp
|
|
170
|
+
```
|
|
171
|
+
|
|
172
|
+
**Claude Code**
|
|
173
|
+
|
|
174
|
+
```powershell
|
|
175
|
+
claude mcp add slots -- slots-mcp
|
|
176
|
+
```
|
|
177
|
+
|
|
178
|
+
## Notes
|
|
179
|
+
|
|
180
|
+
Slots is not a replacement for Git - it is designed to work alongside it as a lightweight local checkpoint tool.
|
|
181
|
+
|
|
182
|
+
Before loading a slot, Slots creates a backup in `.slots/backups/`. You can restore the latest load with:
|
|
183
|
+
|
|
184
|
+
```powershell
|
|
185
|
+
slots revert
|
|
186
|
+
```
|
slots_cli-0.1.0/setup.py
ADDED
|
@@ -0,0 +1,22 @@
|
|
|
1
|
+
from setuptools import find_packages, setup
|
|
2
|
+
|
|
3
|
+
setup(
|
|
4
|
+
name="slots-cli",
|
|
5
|
+
version="0.1.0",
|
|
6
|
+
packages=find_packages(),
|
|
7
|
+
description="Save slots for coding projects.",
|
|
8
|
+
author="hohohocch",
|
|
9
|
+
url="https://slots.hohohocch.com",
|
|
10
|
+
python_requires=">=3.10",
|
|
11
|
+
install_requires=["colorama"],
|
|
12
|
+
entry_points={
|
|
13
|
+
"console_scripts": [
|
|
14
|
+
"slots = slots.__main__:main",
|
|
15
|
+
"slots-mcp = slots.slots_mcp:main",
|
|
16
|
+
],
|
|
17
|
+
},
|
|
18
|
+
extras_require={
|
|
19
|
+
"dev": ["pytest"],
|
|
20
|
+
"mcp": ["mcp[cli]"],
|
|
21
|
+
},
|
|
22
|
+
)
|
|
@@ -0,0 +1,66 @@
|
|
|
1
|
+
import argparse
|
|
2
|
+
from slots.slots import Slots
|
|
3
|
+
|
|
4
|
+
def main():
|
|
5
|
+
slots = Slots()
|
|
6
|
+
|
|
7
|
+
parser = argparse.ArgumentParser(description="Slots - Save slots for coding projects.")
|
|
8
|
+
|
|
9
|
+
subparsers = parser.add_subparsers(title="Commands", dest="command")
|
|
10
|
+
|
|
11
|
+
init_parser = subparsers.add_parser("init", help="Initialize slots")
|
|
12
|
+
init_parser.add_argument("--refresh", action="store_true", help="Update base to current repository state.")
|
|
13
|
+
|
|
14
|
+
save_parser = subparsers.add_parser("save", help="Save current repository.")
|
|
15
|
+
save_parser.add_argument("name")
|
|
16
|
+
|
|
17
|
+
reset_parser = subparsers.add_parser("reset", help="Destroys all slot-related files and folders.")
|
|
18
|
+
|
|
19
|
+
load_parser = subparsers.add_parser("load", help="Load a save slot.")
|
|
20
|
+
load_parser.add_argument("name")
|
|
21
|
+
load_parser.add_argument("--remove-files", action="store_true", help="Remove files that were deleted in the slot.")
|
|
22
|
+
|
|
23
|
+
delete_parser = subparsers.add_parser("delete", help="Delete a save slot.")
|
|
24
|
+
delete_parser.add_argument("name")
|
|
25
|
+
|
|
26
|
+
list_parser = subparsers.add_parser("list", help="List save slots.")
|
|
27
|
+
|
|
28
|
+
revert_parser = subparsers.add_parser("revert", help="Revert the last load.")
|
|
29
|
+
|
|
30
|
+
status_parser = subparsers.add_parser("status", help="View differences between current repository and base state.")
|
|
31
|
+
status_parser.add_argument("--latest", action="store_true", help="Compare current repository to the most recent save.")
|
|
32
|
+
|
|
33
|
+
args = parser.parse_args()
|
|
34
|
+
|
|
35
|
+
match args.command:
|
|
36
|
+
case "init":
|
|
37
|
+
slots.init(args)
|
|
38
|
+
|
|
39
|
+
case "save":
|
|
40
|
+
slots.save(args)
|
|
41
|
+
|
|
42
|
+
case "reset":
|
|
43
|
+
slots.reset()
|
|
44
|
+
|
|
45
|
+
case "load":
|
|
46
|
+
slots.load(args)
|
|
47
|
+
|
|
48
|
+
case "list":
|
|
49
|
+
slots.list()
|
|
50
|
+
|
|
51
|
+
case "delete":
|
|
52
|
+
slots.delete(args)
|
|
53
|
+
|
|
54
|
+
case "revert":
|
|
55
|
+
slots.revert()
|
|
56
|
+
|
|
57
|
+
case "status":
|
|
58
|
+
slots.status(args)
|
|
59
|
+
|
|
60
|
+
case _:
|
|
61
|
+
print("Command not found. Use --help for list of commands.")
|
|
62
|
+
|
|
63
|
+
|
|
64
|
+
if __name__ == "__main__":
|
|
65
|
+
main()
|
|
66
|
+
|
|
@@ -0,0 +1,214 @@
|
|
|
1
|
+
import json
|
|
2
|
+
import shutil
|
|
3
|
+
from datetime import datetime, timezone
|
|
4
|
+
|
|
5
|
+
from colorama import Fore
|
|
6
|
+
|
|
7
|
+
from slots.save import save_current_directory
|
|
8
|
+
from slots.utils import atomic_json, copy_to_destination, remove_file_path, safe_project_path, safe_storage_path
|
|
9
|
+
|
|
10
|
+
def read_slot_layout(path):
|
|
11
|
+
with open(path, "r", encoding="utf-8") as file:
|
|
12
|
+
try:
|
|
13
|
+
layout = json.load(file)
|
|
14
|
+
except json.JSONDecodeError:
|
|
15
|
+
print(Fore.RED + f"Cannot load slot: {path.name} is malformed.")
|
|
16
|
+
return None
|
|
17
|
+
|
|
18
|
+
for key in ("added", "modified", "removed"):
|
|
19
|
+
if not isinstance(layout.get(key), list):
|
|
20
|
+
print(Fore.RED + f"Cannot load slot: layout.json missing '{key}' list.")
|
|
21
|
+
return None
|
|
22
|
+
|
|
23
|
+
return layout
|
|
24
|
+
|
|
25
|
+
def create_load_backup(root, base_dir, backups_dir, slot_name, touched_paths):
|
|
26
|
+
backups_dir.mkdir(parents=True, exist_ok=True)
|
|
27
|
+
timestamp = datetime.now(timezone.utc).strftime("%Y%m%d-%H%M%S-%f")
|
|
28
|
+
|
|
29
|
+
backup_name = f"pre-load-{slot_name}-{timestamp}"
|
|
30
|
+
if not save_current_directory(root, backup_name, base_dir, backups_dir):
|
|
31
|
+
return None
|
|
32
|
+
|
|
33
|
+
restore_path = backups_dir / backup_name / "restore.json"
|
|
34
|
+
|
|
35
|
+
atomic_json(restore_path, {"touched": sorted(set(touched_paths))})
|
|
36
|
+
|
|
37
|
+
|
|
38
|
+
return backup_name
|
|
39
|
+
|
|
40
|
+
def load_save(name, saves_dir, base_dir, backups_dir, root, removeFiles=False):
|
|
41
|
+
save_dir = saves_dir / name
|
|
42
|
+
layout_path = save_dir / "layout.json"
|
|
43
|
+
|
|
44
|
+
if not layout_path.exists() or not (base_dir / "layout.json").exists():
|
|
45
|
+
print(Fore.RED + f"Cannot load slot '{name}': layout.json missing.")
|
|
46
|
+
return False
|
|
47
|
+
|
|
48
|
+
layout = read_slot_layout(layout_path)
|
|
49
|
+
if layout is None:
|
|
50
|
+
return False
|
|
51
|
+
|
|
52
|
+
paths_to_copy = layout["added"] + layout["modified"]
|
|
53
|
+
paths_to_remove = layout["removed"] if removeFiles else []
|
|
54
|
+
|
|
55
|
+
try:
|
|
56
|
+
copy_operations = []
|
|
57
|
+
saved_files_dir = save_dir / "files"
|
|
58
|
+
for file_path in paths_to_copy:
|
|
59
|
+
destination = safe_project_path(root, file_path)
|
|
60
|
+
source = safe_storage_path(saved_files_dir, file_path)
|
|
61
|
+
copy_operations.append((source, destination))
|
|
62
|
+
|
|
63
|
+
remove_operations = [safe_project_path(root, file_path) for file_path in paths_to_remove]
|
|
64
|
+
except ValueError as error:
|
|
65
|
+
print(Fore.RED + str(error))
|
|
66
|
+
return False
|
|
67
|
+
|
|
68
|
+
for source, _ in copy_operations:
|
|
69
|
+
if not source.exists():
|
|
70
|
+
print(Fore.RED + f"Cannot load slot: saved file missing: {source}")
|
|
71
|
+
return False
|
|
72
|
+
|
|
73
|
+
for path in remove_operations:
|
|
74
|
+
if path.exists() and path.is_dir():
|
|
75
|
+
print(Fore.RED + f"Refusing to remove directory during load: {path}")
|
|
76
|
+
return False
|
|
77
|
+
|
|
78
|
+
try:
|
|
79
|
+
backup_name = create_load_backup(root, base_dir, backups_dir, name, paths_to_copy + paths_to_remove)
|
|
80
|
+
except (OSError, json.JSONDecodeError) as error:
|
|
81
|
+
print(Fore.RED + f"Cannot create pre-load backup: {error}")
|
|
82
|
+
return False
|
|
83
|
+
|
|
84
|
+
if backup_name is None:
|
|
85
|
+
return False
|
|
86
|
+
|
|
87
|
+
try:
|
|
88
|
+
for source, destination in copy_operations:
|
|
89
|
+
copy_to_destination(source, destination)
|
|
90
|
+
|
|
91
|
+
for path in sorted(remove_operations, key=lambda item: len(item.parts), reverse=True):
|
|
92
|
+
remove_file_path(path)
|
|
93
|
+
|
|
94
|
+
except (OSError, shutil.Error) as error:
|
|
95
|
+
print(Fore.RED + f"Load failed: {error}")
|
|
96
|
+
print(Fore.YELLOW + f"Run 'slots revert' to restore backup '{backup_name}'.")
|
|
97
|
+
return False
|
|
98
|
+
|
|
99
|
+
return True
|
|
100
|
+
|
|
101
|
+
def find_latest_backup(backups_dir):
|
|
102
|
+
latest_backup = None
|
|
103
|
+
latest_time = None
|
|
104
|
+
|
|
105
|
+
for backup in backups_dir.iterdir():
|
|
106
|
+
info_path = backup / "info.json"
|
|
107
|
+
|
|
108
|
+
if not backup.is_dir() or not info_path.exists():
|
|
109
|
+
continue
|
|
110
|
+
|
|
111
|
+
with open(info_path, "r", encoding="utf-8") as file:
|
|
112
|
+
try:
|
|
113
|
+
info = json.load(file)
|
|
114
|
+
except json.JSONDecodeError:
|
|
115
|
+
continue
|
|
116
|
+
|
|
117
|
+
try:
|
|
118
|
+
saved_time = datetime.fromisoformat(info["time"])
|
|
119
|
+
except (KeyError, TypeError, ValueError):
|
|
120
|
+
continue
|
|
121
|
+
|
|
122
|
+
if latest_time is None or saved_time > latest_time:
|
|
123
|
+
latest_backup = backup
|
|
124
|
+
latest_time = saved_time
|
|
125
|
+
|
|
126
|
+
return latest_backup
|
|
127
|
+
|
|
128
|
+
def read_restore_manifest(path):
|
|
129
|
+
if not path.exists():
|
|
130
|
+
print(Fore.RED + "Cannot revert: restore.json missing.")
|
|
131
|
+
return None
|
|
132
|
+
|
|
133
|
+
with open(path, "r", encoding="utf-8") as file:
|
|
134
|
+
try:
|
|
135
|
+
manifest = json.load(file)
|
|
136
|
+
except json.JSONDecodeError:
|
|
137
|
+
print(Fore.RED + "Cannot revert: restore.json is malformed.")
|
|
138
|
+
return None
|
|
139
|
+
|
|
140
|
+
if not isinstance(manifest.get("touched"), list):
|
|
141
|
+
print(Fore.RED + "Cannot revert: restore.json missing 'touched' list.")
|
|
142
|
+
return None
|
|
143
|
+
|
|
144
|
+
return manifest
|
|
145
|
+
|
|
146
|
+
def revert_latest(root, base_dir, backups_dir):
|
|
147
|
+
backup = find_latest_backup(backups_dir)
|
|
148
|
+
|
|
149
|
+
if backup is None:
|
|
150
|
+
print(Fore.RED + "No load backup found.")
|
|
151
|
+
return False
|
|
152
|
+
|
|
153
|
+
layout = read_slot_layout(backup / "layout.json")
|
|
154
|
+
if layout is None:
|
|
155
|
+
return False
|
|
156
|
+
|
|
157
|
+
manifest = read_restore_manifest(backup / "restore.json")
|
|
158
|
+
if manifest is None:
|
|
159
|
+
return False
|
|
160
|
+
|
|
161
|
+
try:
|
|
162
|
+
with open(base_dir / "layout.json", "r", encoding="utf-8") as file:
|
|
163
|
+
base_layout = json.load(file)
|
|
164
|
+
except (OSError, json.JSONDecodeError) as error:
|
|
165
|
+
print(Fore.RED + f"Cannot revert: base layout unavailable: {error}")
|
|
166
|
+
return False
|
|
167
|
+
|
|
168
|
+
backup_paths = set(layout["added"] + layout["modified"])
|
|
169
|
+
removed_before_load = set(layout["removed"])
|
|
170
|
+
|
|
171
|
+
try:
|
|
172
|
+
copy_operations = []
|
|
173
|
+
remove_operations = []
|
|
174
|
+
backup_files_dir = backup / "files"
|
|
175
|
+
base_files_dir = base_dir / "files"
|
|
176
|
+
|
|
177
|
+
for file_path in manifest["touched"]:
|
|
178
|
+
destination = safe_project_path(root, file_path)
|
|
179
|
+
|
|
180
|
+
if file_path in backup_paths:
|
|
181
|
+
source = safe_storage_path(backup_files_dir, file_path)
|
|
182
|
+
copy_operations.append((source, destination))
|
|
183
|
+
elif file_path in removed_before_load or file_path not in base_layout:
|
|
184
|
+
remove_operations.append(destination)
|
|
185
|
+
else:
|
|
186
|
+
source = safe_storage_path(base_files_dir, file_path)
|
|
187
|
+
copy_operations.append((source, destination))
|
|
188
|
+
|
|
189
|
+
except ValueError as error:
|
|
190
|
+
print(Fore.RED + str(error))
|
|
191
|
+
return False
|
|
192
|
+
|
|
193
|
+
for source, _ in copy_operations:
|
|
194
|
+
if not source.exists():
|
|
195
|
+
print(Fore.RED + f"Cannot revert: backup file missing: {source}")
|
|
196
|
+
return False
|
|
197
|
+
|
|
198
|
+
for path in remove_operations:
|
|
199
|
+
if path.exists() and path.is_dir():
|
|
200
|
+
print(Fore.RED + f"Refusing to remove directory during revert: {path}")
|
|
201
|
+
return False
|
|
202
|
+
|
|
203
|
+
try:
|
|
204
|
+
for source, destination in copy_operations:
|
|
205
|
+
copy_to_destination(source, destination)
|
|
206
|
+
|
|
207
|
+
for path in sorted(remove_operations, key=lambda item: len(item.parts), reverse=True):
|
|
208
|
+
remove_file_path(path)
|
|
209
|
+
|
|
210
|
+
except OSError as error:
|
|
211
|
+
print(Fore.RED + f"Revert failed: {error}")
|
|
212
|
+
return False
|
|
213
|
+
|
|
214
|
+
return True
|
|
@@ -0,0 +1,108 @@
|
|
|
1
|
+
import json
|
|
2
|
+
import shutil
|
|
3
|
+
from datetime import datetime, timezone
|
|
4
|
+
|
|
5
|
+
from colorama import Fore
|
|
6
|
+
|
|
7
|
+
from slots.utils import (
|
|
8
|
+
copy_to_destination,
|
|
9
|
+
create_layout,
|
|
10
|
+
safe_project_path,
|
|
11
|
+
safe_storage_path,
|
|
12
|
+
should_ignore,
|
|
13
|
+
slots_dir_name,
|
|
14
|
+
atomic_json
|
|
15
|
+
)
|
|
16
|
+
|
|
17
|
+
def create_base(root):
|
|
18
|
+
base_dir = root / slots_dir_name / "base"
|
|
19
|
+
base_files_dir = base_dir / "files"
|
|
20
|
+
|
|
21
|
+
for item in root.iterdir():
|
|
22
|
+
if should_ignore(item, root) or item.is_symlink():
|
|
23
|
+
continue
|
|
24
|
+
|
|
25
|
+
destination = base_files_dir / item.name
|
|
26
|
+
|
|
27
|
+
if destination.exists():
|
|
28
|
+
continue
|
|
29
|
+
|
|
30
|
+
try:
|
|
31
|
+
copy_to_destination(item, destination)
|
|
32
|
+
except PermissionError:
|
|
33
|
+
print(Fore.RED + f"Skipped {item} - Currently in use or no permission.")
|
|
34
|
+
|
|
35
|
+
def refresh_base(root):
|
|
36
|
+
base_dir = root / slots_dir_name / "base"
|
|
37
|
+
base_files_dir = base_dir / "files"
|
|
38
|
+
|
|
39
|
+
if base_dir.exists():
|
|
40
|
+
shutil.rmtree(base_dir)
|
|
41
|
+
|
|
42
|
+
base_files_dir.mkdir(parents=True, exist_ok=True)
|
|
43
|
+
create_base(root)
|
|
44
|
+
|
|
45
|
+
base_layout = create_layout(base_files_dir)
|
|
46
|
+
|
|
47
|
+
atomic_json(base_dir / "layout.json", base_layout)
|
|
48
|
+
|
|
49
|
+
def save_current_directory(root, name, base_dir, saves_dir):
|
|
50
|
+
current_layout = create_layout(root)
|
|
51
|
+
|
|
52
|
+
with open(base_dir / "layout.json", "r", encoding="utf-8") as base_layout_file:
|
|
53
|
+
base_layout = json.load(base_layout_file)
|
|
54
|
+
|
|
55
|
+
try:
|
|
56
|
+
for file_path in base_layout:
|
|
57
|
+
safe_project_path(root, file_path)
|
|
58
|
+
except ValueError as error:
|
|
59
|
+
print(Fore.RED + f"Cannot save: {error}")
|
|
60
|
+
return False
|
|
61
|
+
|
|
62
|
+
base_paths = set(base_layout)
|
|
63
|
+
current_paths = set(current_layout)
|
|
64
|
+
|
|
65
|
+
added = current_paths - base_paths
|
|
66
|
+
removed = base_paths - current_paths
|
|
67
|
+
shared = base_paths & current_paths
|
|
68
|
+
|
|
69
|
+
modified_files = []
|
|
70
|
+
|
|
71
|
+
for file_path in shared:
|
|
72
|
+
if base_layout[file_path]["hash"] != current_layout[file_path]["hash"]:
|
|
73
|
+
modified_files.append(file_path)
|
|
74
|
+
|
|
75
|
+
current_save_dir = saves_dir / name
|
|
76
|
+
saved_files_dir = current_save_dir / "files"
|
|
77
|
+
current_save_dir.mkdir(parents=True, exist_ok=True)
|
|
78
|
+
saved_files_dir.mkdir(parents=True, exist_ok=True)
|
|
79
|
+
|
|
80
|
+
atomic_json(current_save_dir / "info.json", {
|
|
81
|
+
"name": name,
|
|
82
|
+
"time": datetime.now(timezone.utc).isoformat()
|
|
83
|
+
})
|
|
84
|
+
|
|
85
|
+
atomic_json(current_save_dir / "layout.json", {
|
|
86
|
+
"added": sorted(added),
|
|
87
|
+
"removed": sorted(removed),
|
|
88
|
+
"modified": sorted(modified_files)
|
|
89
|
+
})
|
|
90
|
+
|
|
91
|
+
# TODO: switch to diff saving instead of saving entire files
|
|
92
|
+
for file_path in list(modified_files) + list(added):
|
|
93
|
+
try:
|
|
94
|
+
source = safe_project_path(root, file_path)
|
|
95
|
+
destination = safe_storage_path(saved_files_dir, file_path)
|
|
96
|
+
except ValueError as error:
|
|
97
|
+
print(Fore.RED + f"Cannot save: {error}")
|
|
98
|
+
return False
|
|
99
|
+
|
|
100
|
+
if not source.exists():
|
|
101
|
+
continue
|
|
102
|
+
|
|
103
|
+
try:
|
|
104
|
+
copy_to_destination(source, destination)
|
|
105
|
+
except PermissionError:
|
|
106
|
+
print(Fore.RED + f"Skipped {source} - Currently in use or no permission.")
|
|
107
|
+
|
|
108
|
+
return True
|