shiggles 0.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.
- shiggles/__init__.py +3 -0
- shiggles/__main__.py +4 -0
- shiggles/cli.py +67 -0
- shiggles/commands.py +414 -0
- shiggles/conflicts.py +98 -0
- shiggles/ignore.py +52 -0
- shiggles/paths.py +31 -0
- shiggles/remote.py +460 -0
- shiggles/repo.py +140 -0
- shiggles/store.py +86 -0
- shiggles/sync.py +33 -0
- shiggles/trees.py +166 -0
- shiggles/util.py +40 -0
- shiggles-0.2.0.dist-info/METADATA +240 -0
- shiggles-0.2.0.dist-info/RECORD +19 -0
- shiggles-0.2.0.dist-info/WHEEL +5 -0
- shiggles-0.2.0.dist-info/entry_points.txt +2 -0
- shiggles-0.2.0.dist-info/licenses/LICENSE +34 -0
- shiggles-0.2.0.dist-info/top_level.txt +1 -0
shiggles/__init__.py
ADDED
shiggles/__main__.py
ADDED
shiggles/cli.py
ADDED
|
@@ -0,0 +1,67 @@
|
|
|
1
|
+
"""CLI entry point for shig."""
|
|
2
|
+
|
|
3
|
+
import sys
|
|
4
|
+
|
|
5
|
+
from shiggles import __version__
|
|
6
|
+
from shiggles.commands import (
|
|
7
|
+
cmd_bring,
|
|
8
|
+
cmd_changes,
|
|
9
|
+
cmd_download,
|
|
10
|
+
cmd_history,
|
|
11
|
+
cmd_load,
|
|
12
|
+
cmd_save,
|
|
13
|
+
cmd_start,
|
|
14
|
+
cmd_tab,
|
|
15
|
+
cmd_timeline,
|
|
16
|
+
cmd_undo,
|
|
17
|
+
cmd_untab,
|
|
18
|
+
cmd_upload,
|
|
19
|
+
)
|
|
20
|
+
from shiggles.util import die
|
|
21
|
+
|
|
22
|
+
COMMANDS = {
|
|
23
|
+
"start": cmd_start,
|
|
24
|
+
"save": cmd_save,
|
|
25
|
+
"load": cmd_load,
|
|
26
|
+
"changes": cmd_changes,
|
|
27
|
+
"history": cmd_history,
|
|
28
|
+
"undo": cmd_undo,
|
|
29
|
+
"tab": cmd_tab,
|
|
30
|
+
"untab": cmd_untab,
|
|
31
|
+
"bring": cmd_bring,
|
|
32
|
+
"download": cmd_download,
|
|
33
|
+
"upload": cmd_upload,
|
|
34
|
+
"timeline": cmd_timeline,
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
HELP = f"""Shiggles v{__version__} - for git and shiggles.
|
|
38
|
+
|
|
39
|
+
Commands:
|
|
40
|
+
shig start set up this folder
|
|
41
|
+
shig save "message" snapshot current tab
|
|
42
|
+
shig save --yes "message" save even if remote is ahead (skip nudge)
|
|
43
|
+
shig load ["message"] put files back to a save (becomes current)
|
|
44
|
+
shig changes what's different since last save
|
|
45
|
+
shig history saves on current tab
|
|
46
|
+
shig tab [name] list tabs, or switch / create
|
|
47
|
+
shig untab <name> remove a tab (history kept)
|
|
48
|
+
shig bring <tab> land current tab into another (usually main)
|
|
49
|
+
shig download [path] get latest current tab (folder or URL)
|
|
50
|
+
shig upload [path] send current tab (folder or URL)
|
|
51
|
+
shig undo discard unsaved changes, restore last save
|
|
52
|
+
shig timeline operation log
|
|
53
|
+
"""
|
|
54
|
+
|
|
55
|
+
|
|
56
|
+
def main():
|
|
57
|
+
if len(sys.argv) < 2 or sys.argv[1] in ("-h", "--help", "help"):
|
|
58
|
+
print(HELP)
|
|
59
|
+
return
|
|
60
|
+
cmd = sys.argv[1]
|
|
61
|
+
if cmd not in COMMANDS:
|
|
62
|
+
die(f"Unknown command: {cmd}\n -> shig help")
|
|
63
|
+
COMMANDS[cmd](sys.argv[2:])
|
|
64
|
+
|
|
65
|
+
|
|
66
|
+
if __name__ == "__main__":
|
|
67
|
+
main()
|
shiggles/commands.py
ADDED
|
@@ -0,0 +1,414 @@
|
|
|
1
|
+
"""All shig commands."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import time
|
|
6
|
+
from pathlib import Path
|
|
7
|
+
|
|
8
|
+
from shiggles.conflicts import (
|
|
9
|
+
find_conflicts,
|
|
10
|
+
format_conflict_message,
|
|
11
|
+
merge_indices,
|
|
12
|
+
park_files,
|
|
13
|
+
)
|
|
14
|
+
from shiggles.ignore import ensure_shignore
|
|
15
|
+
from shiggles.paths import DEFAULT_TAB, SHIG
|
|
16
|
+
from shiggles.repo import (
|
|
17
|
+
current_tab,
|
|
18
|
+
get_tab_commit,
|
|
19
|
+
init_repo,
|
|
20
|
+
list_saves_for_tab,
|
|
21
|
+
list_tabs,
|
|
22
|
+
log_event,
|
|
23
|
+
merge_timeline_lines,
|
|
24
|
+
merged_timeline_for_remote,
|
|
25
|
+
read_timeline_lines,
|
|
26
|
+
require_repo,
|
|
27
|
+
set_current_tab,
|
|
28
|
+
set_tab_commit,
|
|
29
|
+
tab_fork,
|
|
30
|
+
tab_ref,
|
|
31
|
+
)
|
|
32
|
+
from shiggles.store import parse_commit, write_commit
|
|
33
|
+
from shiggles.remote import LocalRemote, Remote, get_remote, is_ancestor, resolve_remote_arg
|
|
34
|
+
from shiggles.trees import (
|
|
35
|
+
build_tree_index,
|
|
36
|
+
commit_preview,
|
|
37
|
+
load_tree,
|
|
38
|
+
working_tree_index,
|
|
39
|
+
write_tree,
|
|
40
|
+
write_tree_from_index,
|
|
41
|
+
)
|
|
42
|
+
from shiggles.util import author_name, die, human_ago, load_config, save_config
|
|
43
|
+
|
|
44
|
+
ROOT = Path(".")
|
|
45
|
+
|
|
46
|
+
|
|
47
|
+
def _fork_index(tab: str) -> dict[str, str]:
|
|
48
|
+
fork = tab_fork(tab)
|
|
49
|
+
if not fork.exists():
|
|
50
|
+
return {}
|
|
51
|
+
commit = fork.read_text(encoding="utf-8").strip()
|
|
52
|
+
if not commit:
|
|
53
|
+
return {}
|
|
54
|
+
info = parse_commit(commit)
|
|
55
|
+
return build_tree_index(info["tree"])
|
|
56
|
+
|
|
57
|
+
|
|
58
|
+
def _maybe_autosave(message: str):
|
|
59
|
+
head = get_tab_commit()
|
|
60
|
+
current = working_tree_index(ROOT)
|
|
61
|
+
previous = build_tree_index(parse_commit(head)["tree"]) if head else {}
|
|
62
|
+
if current == previous:
|
|
63
|
+
return
|
|
64
|
+
_do_save(message, quiet=True)
|
|
65
|
+
|
|
66
|
+
|
|
67
|
+
def _do_save(message: str, *, quiet: bool = False):
|
|
68
|
+
tree = write_tree(ROOT)
|
|
69
|
+
tab = current_tab()
|
|
70
|
+
parent = get_tab_commit(tab)
|
|
71
|
+
|
|
72
|
+
if parent:
|
|
73
|
+
if parse_commit(parent)["tree"] == tree:
|
|
74
|
+
die("Nothing changed since last save.\n -> shig changes")
|
|
75
|
+
|
|
76
|
+
commit_hash = write_commit(tree, parent, message, author_name(), int(time.time()))
|
|
77
|
+
set_tab_commit(tab, commit_hash)
|
|
78
|
+
log_event(f"save [{tab}] {commit_hash[:10]} {message!r}")
|
|
79
|
+
if not quiet:
|
|
80
|
+
print(f'Saved on {tab}: "{message}"')
|
|
81
|
+
|
|
82
|
+
|
|
83
|
+
def cmd_start(_args):
|
|
84
|
+
if SHIG.exists():
|
|
85
|
+
die("Already a Shiggles repo here.\n -> nothing to do.")
|
|
86
|
+
init_repo()
|
|
87
|
+
ensure_shignore()
|
|
88
|
+
log_event("start")
|
|
89
|
+
print("Watching this folder.")
|
|
90
|
+
print(' -> shig save "your first version"')
|
|
91
|
+
|
|
92
|
+
|
|
93
|
+
def _maybe_nudge_download(*, force: bool):
|
|
94
|
+
cfg = load_config()
|
|
95
|
+
if "remote" not in cfg:
|
|
96
|
+
return
|
|
97
|
+
try:
|
|
98
|
+
remote = get_remote(cfg["remote"])
|
|
99
|
+
except SystemExit:
|
|
100
|
+
return
|
|
101
|
+
tab = current_tab()
|
|
102
|
+
local_commit = get_tab_commit(tab)
|
|
103
|
+
remote_commit = remote.read_tab_commit(tab)
|
|
104
|
+
if not remote_commit or remote_commit == local_commit:
|
|
105
|
+
return
|
|
106
|
+
if local_commit:
|
|
107
|
+
remote.sync_objects_from(remote_commit)
|
|
108
|
+
if is_ancestor(local_commit, remote_commit):
|
|
109
|
+
print(f"Someone else saved to {tab} since your last download.")
|
|
110
|
+
print(" -> shig download first")
|
|
111
|
+
if force:
|
|
112
|
+
return
|
|
113
|
+
try:
|
|
114
|
+
answer = input("Save anyway? [y/N] ").strip().lower()
|
|
115
|
+
except EOFError:
|
|
116
|
+
die("Cancelled.")
|
|
117
|
+
if answer != "y":
|
|
118
|
+
die("Cancelled. Run shig download first.")
|
|
119
|
+
|
|
120
|
+
|
|
121
|
+
def cmd_save(args):
|
|
122
|
+
require_repo()
|
|
123
|
+
force = False
|
|
124
|
+
if args and args[0] == "--yes":
|
|
125
|
+
force = True
|
|
126
|
+
args = args[1:]
|
|
127
|
+
if not args:
|
|
128
|
+
die('Save needs a message.\n -> shig save "what you did"')
|
|
129
|
+
_maybe_nudge_download(force=force)
|
|
130
|
+
_do_save(args[0])
|
|
131
|
+
|
|
132
|
+
|
|
133
|
+
def cmd_load(args):
|
|
134
|
+
require_repo()
|
|
135
|
+
tab = current_tab()
|
|
136
|
+
head = get_tab_commit()
|
|
137
|
+
saves = list_saves_for_tab(tab)
|
|
138
|
+
if not saves:
|
|
139
|
+
die('No saves yet.\n -> shig save "first version"')
|
|
140
|
+
|
|
141
|
+
target = None
|
|
142
|
+
if args:
|
|
143
|
+
needle = args[0].lower()
|
|
144
|
+
for h, msg in saves:
|
|
145
|
+
if needle in msg.lower() or h.startswith(needle):
|
|
146
|
+
target = h
|
|
147
|
+
break
|
|
148
|
+
if not target:
|
|
149
|
+
die(f'No save matching "{args[0]}".\n -> shig load')
|
|
150
|
+
else:
|
|
151
|
+
print("Pick a save to load:")
|
|
152
|
+
for i, (ch, msg) in enumerate(saves, 1):
|
|
153
|
+
preview = commit_preview(ch)
|
|
154
|
+
print(f" {i}) {msg} - {preview}")
|
|
155
|
+
try:
|
|
156
|
+
choice = input("> ").strip()
|
|
157
|
+
idx = int(choice) - 1
|
|
158
|
+
target = saves[idx][0]
|
|
159
|
+
except (ValueError, IndexError, EOFError):
|
|
160
|
+
die("Pick a number from the list.")
|
|
161
|
+
|
|
162
|
+
info = parse_commit(target)
|
|
163
|
+
load_tree(info["tree"], ROOT)
|
|
164
|
+
set_tab_commit(tab, target)
|
|
165
|
+
log_event(f"load {target[:10]}")
|
|
166
|
+
print(f'Loaded: "{info["message"]}"')
|
|
167
|
+
|
|
168
|
+
|
|
169
|
+
def cmd_changes(_args):
|
|
170
|
+
require_repo()
|
|
171
|
+
head = get_tab_commit()
|
|
172
|
+
current = working_tree_index(ROOT)
|
|
173
|
+
previous = build_tree_index(parse_commit(head)["tree"]) if head else {}
|
|
174
|
+
|
|
175
|
+
added = [p for p in current if p not in previous]
|
|
176
|
+
removed = [p for p in previous if p not in current]
|
|
177
|
+
changed = [p for p in current if p in previous and current[p] != previous[p]]
|
|
178
|
+
|
|
179
|
+
tab = current_tab()
|
|
180
|
+
if not (added or removed or changed):
|
|
181
|
+
print(f"Clean on {tab}. Nothing changed since last save.")
|
|
182
|
+
return
|
|
183
|
+
print(f"Changes on {tab}:")
|
|
184
|
+
for p in added:
|
|
185
|
+
print(f" + {p}")
|
|
186
|
+
for p in changed:
|
|
187
|
+
print(f" ~ {p}")
|
|
188
|
+
for p in removed:
|
|
189
|
+
print(f" - {p}")
|
|
190
|
+
print('\n -> shig save "..." to snapshot these')
|
|
191
|
+
|
|
192
|
+
|
|
193
|
+
def cmd_history(_args):
|
|
194
|
+
require_repo()
|
|
195
|
+
tab = current_tab()
|
|
196
|
+
saves = list_saves_for_tab(tab)
|
|
197
|
+
if not saves:
|
|
198
|
+
print(f"No saves on {tab} yet.\n -> shig save \"first version\"")
|
|
199
|
+
return
|
|
200
|
+
head = get_tab_commit(tab)
|
|
201
|
+
print(f"History on {tab}:")
|
|
202
|
+
for i, (h, msg) in enumerate(saves):
|
|
203
|
+
info = parse_commit(h)
|
|
204
|
+
when = human_ago(info["time"]) if info["time"] else "?"
|
|
205
|
+
who = info["author"] or "?"
|
|
206
|
+
mark = " *" if h == head else ""
|
|
207
|
+
print(f"* {msg}{mark} ({who}, {when})")
|
|
208
|
+
if i < len(saves) - 1:
|
|
209
|
+
print("|")
|
|
210
|
+
|
|
211
|
+
|
|
212
|
+
def cmd_undo(_args):
|
|
213
|
+
require_repo()
|
|
214
|
+
tab = current_tab()
|
|
215
|
+
head = get_tab_commit(tab)
|
|
216
|
+
if not head:
|
|
217
|
+
die('Nothing to undo.\n -> shig save "first version"')
|
|
218
|
+
|
|
219
|
+
info = parse_commit(head)
|
|
220
|
+
current = working_tree_index(ROOT)
|
|
221
|
+
saved = build_tree_index(info["tree"])
|
|
222
|
+
|
|
223
|
+
if current == saved:
|
|
224
|
+
print(f"Clean on {tab}. Already matches last save.")
|
|
225
|
+
return
|
|
226
|
+
|
|
227
|
+
load_tree(info["tree"], ROOT)
|
|
228
|
+
log_event(f"undo [{tab}]")
|
|
229
|
+
print(f'Undone. Restored last save: "{info["message"]}"')
|
|
230
|
+
|
|
231
|
+
|
|
232
|
+
def cmd_tab(args):
|
|
233
|
+
require_repo()
|
|
234
|
+
tabs = list_tabs()
|
|
235
|
+
if not args:
|
|
236
|
+
cur = current_tab()
|
|
237
|
+
print("Tabs:")
|
|
238
|
+
for t in tabs:
|
|
239
|
+
mark = " *" if t == cur else ""
|
|
240
|
+
head = get_tab_commit(t)
|
|
241
|
+
note = " (no saves yet)" if not head else ""
|
|
242
|
+
print(f" {t}{mark}{note}")
|
|
243
|
+
print("\n -> shig tab <name> to switch or create")
|
|
244
|
+
return
|
|
245
|
+
|
|
246
|
+
name = args[0]
|
|
247
|
+
if name == DEFAULT_TAB and name not in tabs:
|
|
248
|
+
tabs.append(name)
|
|
249
|
+
|
|
250
|
+
cur = current_tab()
|
|
251
|
+
if name != cur:
|
|
252
|
+
_maybe_autosave(f"before switching to {name}")
|
|
253
|
+
|
|
254
|
+
if name not in tabs:
|
|
255
|
+
from shiggles.paths import REFS_TABS
|
|
256
|
+
|
|
257
|
+
fork = get_tab_commit(cur) or ""
|
|
258
|
+
REFS_TABS.mkdir(parents=True, exist_ok=True)
|
|
259
|
+
if fork:
|
|
260
|
+
set_tab_commit(name, fork)
|
|
261
|
+
tab_fork(name).parent.mkdir(parents=True, exist_ok=True)
|
|
262
|
+
tab_fork(name).write_text(fork + "\n", encoding="utf-8")
|
|
263
|
+
else:
|
|
264
|
+
tab_ref(name).touch()
|
|
265
|
+
log_event(f"tab new {name} from {cur}")
|
|
266
|
+
|
|
267
|
+
set_current_tab(name)
|
|
268
|
+
head = get_tab_commit(name)
|
|
269
|
+
if head:
|
|
270
|
+
info = parse_commit(head)
|
|
271
|
+
load_tree(info["tree"], ROOT)
|
|
272
|
+
|
|
273
|
+
print(f"On tab: {name}")
|
|
274
|
+
|
|
275
|
+
|
|
276
|
+
def cmd_untab(args):
|
|
277
|
+
require_repo()
|
|
278
|
+
if not args:
|
|
279
|
+
die("Which tab?\n -> shig untab <name>")
|
|
280
|
+
name = args[0]
|
|
281
|
+
if name == DEFAULT_TAB:
|
|
282
|
+
die("Can't remove the main tab.")
|
|
283
|
+
if name not in list_tabs():
|
|
284
|
+
die(f"No tab named {name!r}.")
|
|
285
|
+
if name == current_tab():
|
|
286
|
+
die(f"Switch off {name} first.\n -> shig tab main")
|
|
287
|
+
|
|
288
|
+
tab_ref(name).unlink(missing_ok=True)
|
|
289
|
+
fork = tab_fork(name)
|
|
290
|
+
if fork.exists():
|
|
291
|
+
fork.unlink()
|
|
292
|
+
log_event(f"untab {name}")
|
|
293
|
+
print(f"Removed tab: {name}")
|
|
294
|
+
print(" (saves still in .shig/ - nothing deleted)")
|
|
295
|
+
|
|
296
|
+
|
|
297
|
+
def cmd_bring(args):
|
|
298
|
+
require_repo()
|
|
299
|
+
if not args:
|
|
300
|
+
die("Bring into which tab?\n -> shig bring main")
|
|
301
|
+
target = args[0]
|
|
302
|
+
source = current_tab()
|
|
303
|
+
if source == target:
|
|
304
|
+
die(f"Already on {target}.\n -> shig tab <name> first")
|
|
305
|
+
|
|
306
|
+
if target not in list_tabs():
|
|
307
|
+
die(f"No tab named {target!r}.\n -> shig tab {target}")
|
|
308
|
+
|
|
309
|
+
ours_commit = get_tab_commit(source)
|
|
310
|
+
theirs_commit = get_tab_commit(target)
|
|
311
|
+
if not ours_commit:
|
|
312
|
+
die(f"Nothing on {source} to bring.\n -> shig save first")
|
|
313
|
+
|
|
314
|
+
ours = build_tree_index(parse_commit(ours_commit)["tree"])
|
|
315
|
+
theirs = build_tree_index(parse_commit(theirs_commit)["tree"]) if theirs_commit else {}
|
|
316
|
+
base = _fork_index(source) if source != DEFAULT_TAB else theirs
|
|
317
|
+
|
|
318
|
+
conflicts = find_conflicts(ours, theirs)
|
|
319
|
+
if conflicts:
|
|
320
|
+
park_files(theirs, conflicts, target)
|
|
321
|
+
die(format_conflict_message("bring", target, source, conflicts, target))
|
|
322
|
+
|
|
323
|
+
merged = merge_indices(base, ours, theirs)
|
|
324
|
+
merged_tree = write_tree_from_index(merged)
|
|
325
|
+
parent = theirs_commit
|
|
326
|
+
msg = f"brought {source} into {target}"
|
|
327
|
+
commit_hash = write_commit(merged_tree, parent, msg, author_name(), int(time.time()))
|
|
328
|
+
set_tab_commit(target, commit_hash)
|
|
329
|
+
load_tree(merged_tree, ROOT)
|
|
330
|
+
set_current_tab(target)
|
|
331
|
+
log_event(f"bring {source} -> {target} {commit_hash[:10]}")
|
|
332
|
+
print(f"Brought {source} -> {target}.")
|
|
333
|
+
print(f" Now on {target}.")
|
|
334
|
+
print(" -> shig upload")
|
|
335
|
+
|
|
336
|
+
|
|
337
|
+
def _apply_download(tab: str, local_commit: str | None, remote_commit: str, remote: Remote):
|
|
338
|
+
remote.sync_objects_from(remote_commit)
|
|
339
|
+
remote.copy_tab_meta_from(tab)
|
|
340
|
+
merge_timeline_lines(remote.read_timeline_lines())
|
|
341
|
+
|
|
342
|
+
if remote_commit == local_commit:
|
|
343
|
+
print(f"{tab} is up to date.")
|
|
344
|
+
return
|
|
345
|
+
|
|
346
|
+
if local_commit and is_ancestor(local_commit, remote_commit):
|
|
347
|
+
set_tab_commit(tab, remote_commit)
|
|
348
|
+
load_tree(parse_commit(remote_commit)["tree"], ROOT)
|
|
349
|
+
log_event(f"download [{tab}] {remote_commit[:10]}")
|
|
350
|
+
print(f"Downloaded latest {tab}.")
|
|
351
|
+
return
|
|
352
|
+
|
|
353
|
+
remote_index = build_tree_index(parse_commit(remote_commit)["tree"])
|
|
354
|
+
local_index = build_tree_index(parse_commit(local_commit)["tree"]) if local_commit else {}
|
|
355
|
+
|
|
356
|
+
conflicts = find_conflicts(local_index, remote_index)
|
|
357
|
+
if conflicts:
|
|
358
|
+
park_files(remote_index, conflicts, "remote")
|
|
359
|
+
die(format_conflict_message("download", tab, "remote", conflicts, "remote"))
|
|
360
|
+
|
|
361
|
+
set_tab_commit(tab, remote_commit)
|
|
362
|
+
load_tree(parse_commit(remote_commit)["tree"], ROOT)
|
|
363
|
+
log_event(f"download [{tab}] {remote_commit[:10]}")
|
|
364
|
+
print(f"Downloaded latest {tab}.")
|
|
365
|
+
|
|
366
|
+
|
|
367
|
+
def cmd_download(args):
|
|
368
|
+
require_repo()
|
|
369
|
+
remote, _ = resolve_remote_arg(args)
|
|
370
|
+
if isinstance(remote, LocalRemote) and not Path(remote.path).exists():
|
|
371
|
+
die(f"Can't find: {remote.path}")
|
|
372
|
+
|
|
373
|
+
tab = current_tab()
|
|
374
|
+
remote_commit = remote.read_tab_commit(tab)
|
|
375
|
+
local_commit = get_tab_commit(tab)
|
|
376
|
+
|
|
377
|
+
if not remote_commit:
|
|
378
|
+
print(f"No {tab} on remote yet. Nothing to download.")
|
|
379
|
+
return
|
|
380
|
+
|
|
381
|
+
_apply_download(tab, local_commit, remote_commit, remote)
|
|
382
|
+
|
|
383
|
+
|
|
384
|
+
def cmd_upload(args):
|
|
385
|
+
require_repo()
|
|
386
|
+
remote, _ = resolve_remote_arg(args)
|
|
387
|
+
remote.ensure_upload_ready()
|
|
388
|
+
|
|
389
|
+
tab = current_tab()
|
|
390
|
+
local_commit = get_tab_commit(tab)
|
|
391
|
+
if not local_commit:
|
|
392
|
+
die("Nothing to upload.\n -> shig save first")
|
|
393
|
+
|
|
394
|
+
remote.sync_objects_to(local_commit)
|
|
395
|
+
remote.copy_tab_meta_to(tab)
|
|
396
|
+
remote.write_tab_commit(tab, local_commit)
|
|
397
|
+
log_event(f"upload [{tab}] {local_commit[:10]}")
|
|
398
|
+
local_lines = read_timeline_lines()
|
|
399
|
+
remote.write_timeline_lines(
|
|
400
|
+
merged_timeline_for_remote(local_lines, remote.read_timeline_lines())
|
|
401
|
+
)
|
|
402
|
+
print(f"Uploaded {tab} to {remote.label()}.")
|
|
403
|
+
|
|
404
|
+
|
|
405
|
+
def cmd_timeline(_args):
|
|
406
|
+
require_repo()
|
|
407
|
+
from shiggles.paths import TIMELINE
|
|
408
|
+
|
|
409
|
+
if not TIMELINE.exists() or TIMELINE.stat().st_size == 0:
|
|
410
|
+
print("Timeline empty.")
|
|
411
|
+
return
|
|
412
|
+
for line in TIMELINE.read_text(encoding="utf-8").splitlines():
|
|
413
|
+
ts, text = line.split("\t", 1)
|
|
414
|
+
print(f" {human_ago(int(ts)):>10} {text}")
|
shiggles/conflicts.py
ADDED
|
@@ -0,0 +1,98 @@
|
|
|
1
|
+
import shutil
|
|
2
|
+
from pathlib import Path
|
|
3
|
+
|
|
4
|
+
from shiggles.paths import COLLISIONS
|
|
5
|
+
from shiggles.store import read_object
|
|
6
|
+
from shiggles.trees import build_tree_index
|
|
7
|
+
|
|
8
|
+
|
|
9
|
+
def find_conflicts(ours: dict[str, str], theirs: dict[str, str]) -> list[str]:
|
|
10
|
+
"""Paths where both sides have different blob hashes."""
|
|
11
|
+
conflicts = []
|
|
12
|
+
for path in sorted(set(ours) & set(theirs)):
|
|
13
|
+
if ours[path] != theirs[path]:
|
|
14
|
+
conflicts.append(path)
|
|
15
|
+
return conflicts
|
|
16
|
+
|
|
17
|
+
|
|
18
|
+
def park_files(index: dict[str, str], paths: list[str], source: str):
|
|
19
|
+
"""Write copies of *theirs* into .shig/collisions/<source>/."""
|
|
20
|
+
dest_root = COLLISIONS / source
|
|
21
|
+
for path in paths:
|
|
22
|
+
bh = index[path]
|
|
23
|
+
_, data = read_object(bh)
|
|
24
|
+
dest = dest_root / path
|
|
25
|
+
dest.parent.mkdir(parents=True, exist_ok=True)
|
|
26
|
+
dest.write_bytes(data)
|
|
27
|
+
|
|
28
|
+
|
|
29
|
+
def clear_collision_dir(source: str):
|
|
30
|
+
root = COLLISIONS / source
|
|
31
|
+
if root.exists():
|
|
32
|
+
shutil.rmtree(root)
|
|
33
|
+
|
|
34
|
+
|
|
35
|
+
def merge_indices(
|
|
36
|
+
base: dict[str, str],
|
|
37
|
+
ours: dict[str, str],
|
|
38
|
+
theirs: dict[str, str],
|
|
39
|
+
) -> dict[str, str]:
|
|
40
|
+
"""
|
|
41
|
+
Merge ours (current tab) into theirs (main/remote).
|
|
42
|
+
- Non-overlapping changes from ours apply.
|
|
43
|
+
- theirs wins for unchanged paths.
|
|
44
|
+
- Deletions from ours (in base, not in ours) apply.
|
|
45
|
+
"""
|
|
46
|
+
merged = dict(theirs)
|
|
47
|
+
for path, bh in ours.items():
|
|
48
|
+
if path not in theirs:
|
|
49
|
+
merged[path] = bh
|
|
50
|
+
elif ours[path] == theirs[path]:
|
|
51
|
+
continue
|
|
52
|
+
else:
|
|
53
|
+
# conflict - caller must abort before calling merge
|
|
54
|
+
merged[path] = bh
|
|
55
|
+
for path in base:
|
|
56
|
+
if path in ours:
|
|
57
|
+
continue
|
|
58
|
+
if path in merged:
|
|
59
|
+
del merged[path]
|
|
60
|
+
return merged
|
|
61
|
+
|
|
62
|
+
|
|
63
|
+
def format_conflict_message(
|
|
64
|
+
action: str,
|
|
65
|
+
target: str,
|
|
66
|
+
source: str,
|
|
67
|
+
conflicts: list[str],
|
|
68
|
+
collision_source: str,
|
|
69
|
+
) -> str:
|
|
70
|
+
lines = [
|
|
71
|
+
f"Can't {action} {source} -> {target}.",
|
|
72
|
+
"",
|
|
73
|
+
" Changed on BOTH sides:",
|
|
74
|
+
]
|
|
75
|
+
for p in conflicts:
|
|
76
|
+
lines.append(f" {p}")
|
|
77
|
+
lines.append("")
|
|
78
|
+
lines.append(f" {target.capitalize()}'s copies (for comparison):")
|
|
79
|
+
for p in conflicts:
|
|
80
|
+
lines.append(f" .shig/collisions/{collision_source}/{p}")
|
|
81
|
+
lines.append("")
|
|
82
|
+
lines.append(" Your files are unchanged in the project folder.")
|
|
83
|
+
lines.append("")
|
|
84
|
+
lines.append(" Next steps:")
|
|
85
|
+
if action == "bring":
|
|
86
|
+
lines.append(" 1) Open the collision copies and your files side by side")
|
|
87
|
+
if source != target:
|
|
88
|
+
lines.append(f" 2) shig tab {target}")
|
|
89
|
+
lines.append(" 3) shig download (if using a shared folder)")
|
|
90
|
+
lines.append(' 4) Fix the files, then shig save "..."')
|
|
91
|
+
lines.append(" 5) shig upload")
|
|
92
|
+
elif action == "download":
|
|
93
|
+
lines.append(" 1) Open .shig/collisions/remote/ files and your copies")
|
|
94
|
+
lines.append(' 2) Fix the files, then shig save "..."')
|
|
95
|
+
lines.append(" 3) shig download")
|
|
96
|
+
else:
|
|
97
|
+
lines.append(" Compare, fix, save, then try again.")
|
|
98
|
+
return "\n".join(lines)
|
shiggles/ignore.py
ADDED
|
@@ -0,0 +1,52 @@
|
|
|
1
|
+
import fnmatch
|
|
2
|
+
import os
|
|
3
|
+
from pathlib import Path, PurePosixPath
|
|
4
|
+
|
|
5
|
+
from shiggles.paths import BUILTIN_IGNORE, DEFAULT_SHIGNORE, SHIGNORE
|
|
6
|
+
|
|
7
|
+
|
|
8
|
+
def ensure_shignore():
|
|
9
|
+
if not SHIGNORE.exists():
|
|
10
|
+
SHIGNORE.write_text(DEFAULT_SHIGNORE, encoding="utf-8")
|
|
11
|
+
|
|
12
|
+
|
|
13
|
+
def _read_patterns() -> list[str]:
|
|
14
|
+
if not SHIGNORE.exists():
|
|
15
|
+
return []
|
|
16
|
+
patterns = []
|
|
17
|
+
for line in SHIGNORE.read_text(encoding="utf-8").splitlines():
|
|
18
|
+
line = line.strip()
|
|
19
|
+
if not line or line.startswith("#"):
|
|
20
|
+
continue
|
|
21
|
+
patterns.append(line)
|
|
22
|
+
return patterns
|
|
23
|
+
|
|
24
|
+
|
|
25
|
+
def _rel_path(path: Path, root: Path) -> str | None:
|
|
26
|
+
rel = os.path.relpath(path.resolve(), root.resolve())
|
|
27
|
+
if rel.startswith(".."):
|
|
28
|
+
return None
|
|
29
|
+
return Path(rel).as_posix()
|
|
30
|
+
|
|
31
|
+
|
|
32
|
+
def _matches(rel: str, patterns: list[str]) -> bool:
|
|
33
|
+
name = PurePosixPath(rel).name
|
|
34
|
+
for pat in patterns:
|
|
35
|
+
if pat.endswith("/"):
|
|
36
|
+
dirpat = pat.rstrip("/")
|
|
37
|
+
if rel == dirpat or rel.startswith(dirpat + "/"):
|
|
38
|
+
return True
|
|
39
|
+
if dirpat in PurePosixPath(rel).parts:
|
|
40
|
+
return True
|
|
41
|
+
elif fnmatch.fnmatch(rel, pat) or fnmatch.fnmatch(name, pat):
|
|
42
|
+
return True
|
|
43
|
+
return False
|
|
44
|
+
|
|
45
|
+
|
|
46
|
+
def ignored(path: Path, root: Path) -> bool:
|
|
47
|
+
if path.name in BUILTIN_IGNORE:
|
|
48
|
+
return True
|
|
49
|
+
rel = _rel_path(path, root)
|
|
50
|
+
if rel is None:
|
|
51
|
+
return True
|
|
52
|
+
return _matches(rel, _read_patterns())
|
shiggles/paths.py
ADDED
|
@@ -0,0 +1,31 @@
|
|
|
1
|
+
from pathlib import Path
|
|
2
|
+
|
|
3
|
+
SHIG = Path(".shig")
|
|
4
|
+
OBJECTS = SHIG / "objects"
|
|
5
|
+
REFS_TABS = SHIG / "refs" / "tabs"
|
|
6
|
+
TAB_META = SHIG / "tabs"
|
|
7
|
+
HEAD = SHIG / "HEAD"
|
|
8
|
+
TIMELINE = SHIG / "timeline.log"
|
|
9
|
+
CONFIG = SHIG / "config.json"
|
|
10
|
+
COLLISIONS = SHIG / "collisions"
|
|
11
|
+
SHIGNORE = Path(".shignore")
|
|
12
|
+
|
|
13
|
+
DEFAULT_TAB = "main"
|
|
14
|
+
|
|
15
|
+
BUILTIN_IGNORE = {".shig", ".git", "__pycache__", ".idea", ".vscode", "node_modules"}
|
|
16
|
+
|
|
17
|
+
DEFAULT_SHIGNORE = """\
|
|
18
|
+
# Shiggles - files and folders we never snapshot
|
|
19
|
+
.shig/
|
|
20
|
+
.git/
|
|
21
|
+
__pycache__/
|
|
22
|
+
*.pyc
|
|
23
|
+
.DS_Store
|
|
24
|
+
Thumbs.db
|
|
25
|
+
release/
|
|
26
|
+
build/
|
|
27
|
+
dist/
|
|
28
|
+
bin/
|
|
29
|
+
obj/
|
|
30
|
+
node_modules/
|
|
31
|
+
"""
|