-
Notifications
You must be signed in to change notification settings - Fork 0
Expand file tree
/
Copy pathsync.py
More file actions
160 lines (134 loc) · 6.09 KB
/
Copy pathsync.py
File metadata and controls
160 lines (134 loc) · 6.09 KB
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
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
"""Sync this project's .clutch with the global store at %USERPROFILE%\\.clutch.
python .clutch/sync.py # full two-way sync
python .clutch/sync.py --dry-run # show what would happen
What syncs, and in which direction:
guides/, templates/ two-way, newer file wins (shared canon across projects)
solutions/*.md two-way union, newer wins (the cross-project library)
solutions/INDEX.md regenerated globally from frontmatter, copied down
history/*.md push only -> global history/<project>/ (per-project log)
Stdlib only; Python 3.8+.
"""
import shutil
import sys
from pathlib import Path
sys.path.insert(0, str(Path(__file__).resolve().parent / "scripts"))
from _common import ( # noqa: E402
AI_ROOT, global_dir, load_config, parse_frontmatter, project_name,
)
DRY = "--dry-run" in sys.argv
def log(action, src, dst):
prefix = "[dry-run] " if DRY else ""
print(f"{prefix}{action:8} {src} -> {dst}")
def copy(src: Path, dst: Path, action="copy"):
log(action, src, dst)
if not DRY:
dst.parent.mkdir(parents=True, exist_ok=True)
shutil.copy2(src, dst) # copy2 keeps mtime, which drives newer-wins
def newer_wins(local: Path, remote: Path, names=None):
"""Two-way sync of *.md files between two dirs; newer mtime wins."""
local.mkdir(parents=True, exist_ok=True)
remote.mkdir(parents=True, exist_ok=True)
files = names or sorted(
{p.name for p in local.glob("*.md")} | {p.name for p in remote.glob("*.md")}
)
for name in files:
a, b = local / name, remote / name
if a.exists() and not b.exists():
copy(a, b, "push")
elif b.exists() and not a.exists():
copy(b, a, "pull")
elif a.exists() and b.exists():
da, db = a.stat().st_mtime, b.stat().st_mtime
if abs(da - db) < 1: # same (copy2 preserves mtime)
continue
copy(a, b, "push") if da > db else copy(b, a, "pull")
def newer_wins_tree(local: Path, remote: Path):
"""Recursive two-way sync of every *.md under two dir trees; newer wins."""
local.mkdir(parents=True, exist_ok=True)
remote.mkdir(parents=True, exist_ok=True)
rels = sorted(
{p.relative_to(local) for p in local.rglob("*.md")}
| {p.relative_to(remote) for p in remote.rglob("*.md")}
)
for rel in rels:
a, b = local / rel, remote / rel
if a.exists() and not b.exists():
copy(a, b, "push")
elif b.exists() and not a.exists():
copy(b, a, "pull")
else:
da, db = a.stat().st_mtime, b.stat().st_mtime
if abs(da - db) < 1:
continue
copy(a, b, "push") if da > db else copy(b, a, "pull")
def solution_meta(path: Path):
"""Index fields for one solution file, with defaults for anything absent."""
meta = {"title": path.stem, "tags": [], "date": "", "projects": []}
parsed, _ = parse_frontmatter(path.read_text(encoding="utf-8"))
for key, default in meta.items():
val = parsed.get(key)
if val is None:
continue
# a list-valued key must stay a list even if someone wrote it bare
meta[key] = val if isinstance(val, type(default)) else default
return meta
def regenerate_index(solutions_dir: Path) -> str:
rows = []
for f in sorted(solutions_dir.glob("*.md")):
if f.name == "INDEX.md":
continue
m = solution_meta(f)
tags = ", ".join(m["tags"]) or "-"
projects = ", ".join(m["projects"]) or "-"
rows.append(f"| [{m['title']}]({f.name}) | {tags} | {projects} | {m['date']} |")
body = "\n".join(rows) if rows else "| _no solutions logged yet_ | | | |"
return (
"# Cross-project solutions index\n\n"
"<!-- AUTO-GENERATED by sync.py - do not hand-edit. "
"See guides/SOLUTIONS.md. -->\n\n"
"| Solution | Tags | Found in | Updated |\n|---|---|---|---|\n"
f"{body}\n"
)
def main():
cfg = load_config()
gdir = global_dir(cfg)
proj = project_name(cfg)
role = cfg.get("role", "consumer")
print(f"syncing {AI_ROOT} <-> {gdir} (project: {proj}, role: {role})")
# The library canon (guides/templates/rules/prompts) is only mirrored two-way for
# a 'source' install that owns it. 'consumer' projects read those from the global
# store (via find_resource) and never copy the markdown in - that's the whole point
# of the bundle method. They still sync solutions and push history below.
if role == "source":
for sub in ("guides", "templates"):
newer_wins(AI_ROOT / sub, gdir / sub)
for lib in ("rules", "prompts"):
if (AI_ROOT / lib).is_dir() or (gdir / lib).is_dir():
newer_wins_tree(AI_ROOT / lib, gdir / lib)
elif not (gdir / "guides").is_dir():
print(f"warning: global store at {gdir} has no libraries yet. Set up a "
"'source' install (the clutch repo) and sync it first.", file=sys.stderr)
# 2. solutions: two-way union, newer wins (INDEX.md handled separately)
loc_sol, glob_sol = AI_ROOT / "solutions", gdir / "solutions"
loc_sol.mkdir(exist_ok=True)
glob_sol.mkdir(parents=True, exist_ok=True)
names = sorted(
{p.name for p in loc_sol.glob("*.md")} | {p.name for p in glob_sol.glob("*.md")}
)
newer_wins(loc_sol, glob_sol, [n for n in names if n != "INDEX.md"])
# 3. regenerate the index globally, copy it down
index = regenerate_index(glob_sol)
log("index", glob_sol / "INDEX.md", loc_sol / "INDEX.md")
if not DRY:
(glob_sol / "INDEX.md").write_text(index, encoding="utf-8")
(loc_sol / "INDEX.md").write_text(index, encoding="utf-8")
# 4. history: push-only, local -> global/history/<project>/
loc_hist = AI_ROOT / "history"
if loc_hist.is_dir():
for f in sorted(loc_hist.glob("*.md")):
dst = gdir / "history" / proj / f.name
if not dst.exists() or f.stat().st_mtime > dst.stat().st_mtime + 1:
copy(f, dst, "push")
print("sync complete" + (" (dry run - nothing written)" if DRY else ""))
if __name__ == "__main__":
main()