lockmaster 0.0.1__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.
- lockmaster/__init__.py +3 -0
- lockmaster/__main__.py +3 -0
- lockmaster/cli.py +225 -0
- lockmaster/config.py +195 -0
- lockmaster/doctor.py +146 -0
- lockmaster/drivers.py +93 -0
- lockmaster/generated.py +65 -0
- lockmaster/git.py +102 -0
- lockmaster/install.py +229 -0
- lockmaster/kit/claude/hooks/auto-land.py +286 -0
- lockmaster/kit/claude/hooks/block-main.py +174 -0
- lockmaster/kit/claude/hooks/claim-guard.py +133 -0
- lockmaster/kit/claude/hooks/disk-budget.py +199 -0
- lockmaster/kit/claude/hooks/guard-open-work-inbox.sh +17 -0
- lockmaster/kit/claude/hooks/inject-inbox.sh +54 -0
- lockmaster/kit/claude/hooks/now-ledger.py +371 -0
- lockmaster/kit/claude/hooks/release-locks.sh +34 -0
- lockmaster/kit/claude/hooks/require-progress.sh +15 -0
- lockmaster/kit/claude/hooks/session-lock.sh +58 -0
- lockmaster/kit/claude/hooks/stop-if-blocked.sh +20 -0
- lockmaster/kit/claude/hooks/work-files.py +128 -0
- lockmaster/kit/claude/hooks/worktree-cleanup.py +159 -0
- lockmaster/kit/claude/settings.json +197 -0
- lockmaster/kit/claude/skills/docket/SKILL.md +89 -0
- lockmaster/kit/claude/skills/landing/SKILL.md +68 -0
- lockmaster/kit/work/BACKLOG.md +12 -0
- lockmaster/kit/work/INTERVIEW.md +22 -0
- lockmaster/kit/work/LESSONS.md +12 -0
- lockmaster/kit/work/MACHINE.md +20 -0
- lockmaster/kit/work/OPEN-WORK.md +24 -0
- lockmaster/kit/work/WORKLOG.md +24 -0
- lockmaster/land.py +1328 -0
- lockmaster/merge.py +99 -0
- lockmaster/now.py +92 -0
- lockmaster/repo.py +268 -0
- lockmaster/status.py +119 -0
- lockmaster/surface.py +102 -0
- lockmaster-0.0.1.dist-info/METADATA +130 -0
- lockmaster-0.0.1.dist-info/RECORD +43 -0
- lockmaster-0.0.1.dist-info/WHEEL +5 -0
- lockmaster-0.0.1.dist-info/entry_points.txt +2 -0
- lockmaster-0.0.1.dist-info/licenses/LICENSE +23 -0
- lockmaster-0.0.1.dist-info/top_level.txt +1 -0
lockmaster/__init__.py
ADDED
lockmaster/__main__.py
ADDED
lockmaster/cli.py
ADDED
|
@@ -0,0 +1,225 @@
|
|
|
1
|
+
"""The `lockmaster` command — SR-ARCHITECTURE decision 14.
|
|
2
|
+
|
|
3
|
+
Python >= 3.9, standard library only (decision 2). Exit codes: 0 done, 1 a
|
|
4
|
+
change went red or a check found a problem, 2 refused or misused.
|
|
5
|
+
"""
|
|
6
|
+
import argparse
|
|
7
|
+
import os
|
|
8
|
+
import sys
|
|
9
|
+
|
|
10
|
+
from lockmaster import __version__
|
|
11
|
+
|
|
12
|
+
# command -> what it does (decision 14's table)
|
|
13
|
+
COMMANDS = {
|
|
14
|
+
"start": "branch + worktree + claim for a work item",
|
|
15
|
+
"claim": "widen the claim: claim add <path>…",
|
|
16
|
+
"submit": "commit the claimed paths, join the queue, start landing",
|
|
17
|
+
"run": "land queued changes, one at a time",
|
|
18
|
+
"queue": "what is waiting, landed or red",
|
|
19
|
+
"status": "live sessions, changes, worktrees, disk use; --now prints work/NOW.md",
|
|
20
|
+
"abandon": "drop a change; remove its worktree only if nothing is lost",
|
|
21
|
+
"save": "land edits made in main's checkout; hand them to Claude Code where this surface cannot commit",
|
|
22
|
+
"pickup": "land the edits other surfaces handed over",
|
|
23
|
+
"sync": "land this session's open changes now (the kit's hooks, at the end of a turn)",
|
|
24
|
+
"can-commit": "say whether this surface can commit (exit 0) or must hand off (exit 1)",
|
|
25
|
+
"doctor": "stale locks, orphan worktrees, unmerged branches, kit mismatch",
|
|
26
|
+
"install": "install the kit into a repository, pinned",
|
|
27
|
+
"regen": "rebuild the generated files lockmaster.toml lists",
|
|
28
|
+
"drivers": "register the structural merge drivers; write .gitattributes from [merge]",
|
|
29
|
+
"merge-driver": "what git runs for a structural merge (installed by `drivers`)",
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
|
|
33
|
+
def _parser():
|
|
34
|
+
p = argparse.ArgumentParser(prog="lockmaster",
|
|
35
|
+
description="Lands AI-agent changes into main one at a time, without ever losing work.")
|
|
36
|
+
p.add_argument("--version", action="version", version="lockmaster " + __version__)
|
|
37
|
+
sub = p.add_subparsers(dest="command", metavar="<command>")
|
|
38
|
+
|
|
39
|
+
s = sub.add_parser("start", help=COMMANDS["start"])
|
|
40
|
+
s.add_argument("item")
|
|
41
|
+
s.add_argument("--paths", nargs="+", required=True, metavar="PATH")
|
|
42
|
+
|
|
43
|
+
s = sub.add_parser("claim", help=COMMANDS["claim"])
|
|
44
|
+
s.add_argument("action", choices=["add"])
|
|
45
|
+
s.add_argument("paths", nargs="+")
|
|
46
|
+
s.add_argument("--item")
|
|
47
|
+
|
|
48
|
+
s = sub.add_parser("submit", help=COMMANDS["submit"])
|
|
49
|
+
s.add_argument("-m", "--message", default="")
|
|
50
|
+
s.add_argument("--item")
|
|
51
|
+
s.add_argument("--no-run", action="store_true", help="queue only; do not start landing")
|
|
52
|
+
|
|
53
|
+
sub.add_parser("run", help=COMMANDS["run"])
|
|
54
|
+
sub.add_parser("queue", help=COMMANDS["queue"])
|
|
55
|
+
|
|
56
|
+
s = sub.add_parser("status", help=COMMANDS["status"])
|
|
57
|
+
s.add_argument("--now", action="store_true", help="print work/NOW.md as it should be")
|
|
58
|
+
s.add_argument("--write-now", action="store_true", help="write work/NOW.md")
|
|
59
|
+
|
|
60
|
+
s = sub.add_parser("abandon", help=COMMANDS["abandon"])
|
|
61
|
+
s.add_argument("--item")
|
|
62
|
+
|
|
63
|
+
s = sub.add_parser("save", help=COMMANDS["save"])
|
|
64
|
+
s.add_argument("paths", nargs="+", metavar="PATH")
|
|
65
|
+
s.add_argument("-m", "--message", required=True)
|
|
66
|
+
s.add_argument("--item", help="the work item, when there is one (default: save-<time>)")
|
|
67
|
+
s.add_argument("--by", default="", help="who hands it over, for the record (e.g. \"Cowork\")")
|
|
68
|
+
s.add_argument("--handoff", action="store_true", help="hand off even where this surface could commit")
|
|
69
|
+
|
|
70
|
+
sub.add_parser("pickup", help=COMMANDS["pickup"])
|
|
71
|
+
|
|
72
|
+
s = sub.add_parser("sync", help=COMMANDS["sync"])
|
|
73
|
+
s.add_argument("--session", default="")
|
|
74
|
+
s.add_argument("--cwd", default="")
|
|
75
|
+
s.add_argument("--final", action="store_true", help="the session is ending: land and clean up")
|
|
76
|
+
s.add_argument("--no-adopt", action="store_true", help="do not adopt a worktree lockmaster did not create")
|
|
77
|
+
s.add_argument("--pickup", action="store_true", help="then land the handoffs too")
|
|
78
|
+
|
|
79
|
+
sub.add_parser("can-commit", help=COMMANDS["can-commit"])
|
|
80
|
+
|
|
81
|
+
sub.add_parser("doctor", help=COMMANDS["doctor"])
|
|
82
|
+
|
|
83
|
+
s = sub.add_parser("install", help=COMMANDS["install"])
|
|
84
|
+
s.add_argument("repo", nargs="?", default=".")
|
|
85
|
+
s.add_argument("--backup", action="store_true",
|
|
86
|
+
help="move each file the kit would replace aside to <file>.pre-lockmaster")
|
|
87
|
+
|
|
88
|
+
sub.add_parser("regen", help=COMMANDS["regen"])
|
|
89
|
+
sub.add_parser("drivers", help=COMMANDS["drivers"])
|
|
90
|
+
|
|
91
|
+
s = sub.add_parser("merge-driver", help=COMMANDS["merge-driver"])
|
|
92
|
+
s.add_argument("args", nargs=argparse.REMAINDER)
|
|
93
|
+
return p
|
|
94
|
+
|
|
95
|
+
|
|
96
|
+
def main(argv=None):
|
|
97
|
+
from lockmaster.config import ConfigError
|
|
98
|
+
from lockmaster.repo import LockmasterError
|
|
99
|
+
|
|
100
|
+
args = _parser().parse_args(argv)
|
|
101
|
+
if not args.command:
|
|
102
|
+
_parser().print_help()
|
|
103
|
+
return 0
|
|
104
|
+
try:
|
|
105
|
+
return _dispatch(args)
|
|
106
|
+
except (LockmasterError, ConfigError) as e:
|
|
107
|
+
print("lockmaster {}: {}".format(args.command, e), file=sys.stderr)
|
|
108
|
+
return 2
|
|
109
|
+
|
|
110
|
+
|
|
111
|
+
def _out(text):
|
|
112
|
+
print(text)
|
|
113
|
+
|
|
114
|
+
|
|
115
|
+
def _save(repo, args):
|
|
116
|
+
from lockmaster import land, surface
|
|
117
|
+
paths = land._relative(repo, args.paths)
|
|
118
|
+
ok, why = (False, "asked to hand off") if args.handoff else surface.can_commit(repo)
|
|
119
|
+
if ok:
|
|
120
|
+
change = land.save(repo, paths, args.message, item=args.item, out=_out)
|
|
121
|
+
if change is None:
|
|
122
|
+
print("nothing uncommitted under " + ", ".join(paths))
|
|
123
|
+
return 0
|
|
124
|
+
return 1 if change["status"] == "red" else 0
|
|
125
|
+
h = land.handoff(repo, paths, args.message, item=args.item, by=args.by)
|
|
126
|
+
if h is None:
|
|
127
|
+
print("nothing uncommitted under " + ", ".join(paths))
|
|
128
|
+
return 0
|
|
129
|
+
print("handed off {}: {} file{} ({}).".format(h["item"], len(h["files"]), "s" * (len(h["files"]) != 1), why))
|
|
130
|
+
print("The next Claude Code session in this repository lands it — nothing else to do here.")
|
|
131
|
+
return 0
|
|
132
|
+
|
|
133
|
+
|
|
134
|
+
def _dispatch(args):
|
|
135
|
+
from lockmaster import land
|
|
136
|
+
from lockmaster.repo import Repo
|
|
137
|
+
|
|
138
|
+
if args.command == "merge-driver":
|
|
139
|
+
from lockmaster import merge
|
|
140
|
+
return merge.main(args.args)
|
|
141
|
+
if args.command == "install":
|
|
142
|
+
from lockmaster import install
|
|
143
|
+
install.install(args.repo, backup=args.backup, out=_out)
|
|
144
|
+
return 0
|
|
145
|
+
|
|
146
|
+
repo = Repo(os.getcwd())
|
|
147
|
+
if args.command == "start":
|
|
148
|
+
change, overlaps = land.start(repo, args.item, args.paths, os.environ.get("CLAUDE_SESSION_ID", ""))
|
|
149
|
+
print("started {} on {}: work in {}".format(change["item"], change["branch"], change["worktree"]))
|
|
150
|
+
print("claim: " + ", ".join(change["paths"]))
|
|
151
|
+
for other, path in overlaps:
|
|
152
|
+
print("note: {} also claims {} — whichever lands second may go red".format(other, path))
|
|
153
|
+
return 0
|
|
154
|
+
if args.command == "claim":
|
|
155
|
+
change = land.claim_add(repo, repo.current_change(args.item), args.paths)
|
|
156
|
+
print("claim of {}: {}".format(change["item"], ", ".join(change["paths"])))
|
|
157
|
+
return 0
|
|
158
|
+
if args.command == "submit":
|
|
159
|
+
change = land.submit(repo, repo.current_change(args.item), args.message, out=_out)
|
|
160
|
+
print("queued " + change["item"])
|
|
161
|
+
if args.no_run:
|
|
162
|
+
return 0
|
|
163
|
+
land.run(repo, out=_out)
|
|
164
|
+
return 1 if repo.load(change["item"])["status"] == "red" else 0
|
|
165
|
+
if args.command == "run":
|
|
166
|
+
land.run(repo, out=_out)
|
|
167
|
+
return 0
|
|
168
|
+
if args.command == "queue":
|
|
169
|
+
print("\n".join(land.queue_lines(repo)))
|
|
170
|
+
return 0
|
|
171
|
+
if args.command == "abandon":
|
|
172
|
+
land.abandon(repo, repo.current_change(args.item), out=_out)
|
|
173
|
+
return 0
|
|
174
|
+
if args.command == "save":
|
|
175
|
+
return _save(repo, args)
|
|
176
|
+
if args.command == "pickup":
|
|
177
|
+
results = land.pickup(repo, out=_out)
|
|
178
|
+
if not results:
|
|
179
|
+
print("no handoffs")
|
|
180
|
+
return 1 if any(r in ("red", "failed") for _, r, _ in results) else 0
|
|
181
|
+
if args.command == "sync":
|
|
182
|
+
queued = land.sync(repo, session=args.session or os.environ.get("CLAUDE_SESSION_ID", ""),
|
|
183
|
+
cwd=args.cwd or None, final=args.final, adopt_cwd=not args.no_adopt, out=_out)
|
|
184
|
+
if not queued:
|
|
185
|
+
print("nothing to land")
|
|
186
|
+
if args.pickup:
|
|
187
|
+
land.pickup(repo, out=_out)
|
|
188
|
+
return 0
|
|
189
|
+
if args.command == "can-commit":
|
|
190
|
+
from lockmaster import surface
|
|
191
|
+
ok, why = surface.can_commit(repo)
|
|
192
|
+
print("yes — this surface lands its own changes" if ok else "no — {}; `lockmaster save` hands "
|
|
193
|
+
"edits to the next Claude Code session".format(why))
|
|
194
|
+
return 0 if ok else 1
|
|
195
|
+
if args.command == "status":
|
|
196
|
+
from lockmaster import now, status
|
|
197
|
+
if args.write_now:
|
|
198
|
+
print("work/NOW.md " + ("written" if now.write_if_present(repo) else "not kept here"))
|
|
199
|
+
return 0
|
|
200
|
+
if args.now:
|
|
201
|
+
sys.stdout.write(status.now_text(repo))
|
|
202
|
+
return 0
|
|
203
|
+
print("\n".join(status.report(repo)))
|
|
204
|
+
return 0
|
|
205
|
+
if args.command == "doctor":
|
|
206
|
+
from lockmaster import doctor
|
|
207
|
+
problems = doctor.check(repo)
|
|
208
|
+
print("\n".join(problems) if problems else "all well")
|
|
209
|
+
return 1 if problems else 0
|
|
210
|
+
if args.command == "regen":
|
|
211
|
+
from lockmaster import generated
|
|
212
|
+
done = generated.regen(repo.toplevel, repo.config)
|
|
213
|
+
print("rebuilt: " + (", ".join(done) if done else "nothing — lockmaster.toml lists no [generated] paths"))
|
|
214
|
+
return 0
|
|
215
|
+
if args.command == "drivers":
|
|
216
|
+
from lockmaster import drivers
|
|
217
|
+
changed = drivers.install(repo)
|
|
218
|
+
print("merge drivers registered in .git/config"
|
|
219
|
+
+ ("; .gitattributes written — commit it" if changed else ""))
|
|
220
|
+
return 0
|
|
221
|
+
return 2
|
|
222
|
+
|
|
223
|
+
|
|
224
|
+
if __name__ == "__main__":
|
|
225
|
+
raise SystemExit(main())
|
lockmaster/config.py
ADDED
|
@@ -0,0 +1,195 @@
|
|
|
1
|
+
"""`lockmaster.toml` — SR-ARCHITECTURE decision 13.
|
|
2
|
+
|
|
3
|
+
[main]
|
|
4
|
+
branch = "main" # the branch lockmaster lands onto
|
|
5
|
+
|
|
6
|
+
[checks] # name = command; every one must pass
|
|
7
|
+
tests = "python3 -m unittest discover -s tests"
|
|
8
|
+
|
|
9
|
+
[generated] # path or glob = the command that rebuilds it
|
|
10
|
+
"CHANGELOG.md" = "python3 scripts/changelog.py"
|
|
11
|
+
|
|
12
|
+
[merge] # path or glob = structural driver
|
|
13
|
+
"work/WORKLOG.md" = "append-newest-first"
|
|
14
|
+
|
|
15
|
+
[land] # optional
|
|
16
|
+
auto = "on" # "off": no landing at the end of a turn (decision 20)
|
|
17
|
+
|
|
18
|
+
Read with `tomllib` on Python >= 3.11 and with the subset reader below on
|
|
19
|
+
3.9-3.10. Both feed the same validation, so a file means the same thing on
|
|
20
|
+
every Python: four tables of string values and nothing else. Anything outside
|
|
21
|
+
that subset is a loud error, never a guess (W-03 hazard).
|
|
22
|
+
"""
|
|
23
|
+
import re
|
|
24
|
+
|
|
25
|
+
DRIVERS = ("append-newest-first", "table-rows", "append")
|
|
26
|
+
TABLES = ("main", "checks", "generated", "merge", "land")
|
|
27
|
+
AUTO = ("on", "off")
|
|
28
|
+
FILENAME = "lockmaster.toml"
|
|
29
|
+
|
|
30
|
+
|
|
31
|
+
class ConfigError(Exception):
|
|
32
|
+
pass
|
|
33
|
+
|
|
34
|
+
|
|
35
|
+
class Config:
|
|
36
|
+
def __init__(self, main="main", checks=None, generated=None, merge=None, auto="on"):
|
|
37
|
+
self.main = main
|
|
38
|
+
self.auto_land = auto == "on"
|
|
39
|
+
self.checks = dict(checks or {})
|
|
40
|
+
self.generated = dict(generated or {})
|
|
41
|
+
self.merge = dict(merge or {})
|
|
42
|
+
|
|
43
|
+
def as_dict(self):
|
|
44
|
+
return {"main": {"branch": self.main}, "checks": self.checks,
|
|
45
|
+
"generated": self.generated, "merge": self.merge,
|
|
46
|
+
"land": {"auto": "on" if self.auto_land else "off"}}
|
|
47
|
+
|
|
48
|
+
|
|
49
|
+
# ---------------------------------------------------------------- the subset reader
|
|
50
|
+
|
|
51
|
+
_BARE = re.compile(r"[A-Za-z0-9_-]+")
|
|
52
|
+
_ESCAPES = {"b": "\b", "t": "\t", "n": "\n", "f": "\f", "r": "\r", '"': '"', "\\": "\\"}
|
|
53
|
+
|
|
54
|
+
|
|
55
|
+
def _string(s, i, where):
|
|
56
|
+
"""Parse a basic ("...") or literal ('...') string at s[i]; return (value, next index)."""
|
|
57
|
+
q = s[i]
|
|
58
|
+
if s.startswith(q * 3, i):
|
|
59
|
+
raise ConfigError(where + ": multi-line strings are outside lockmaster.toml's subset")
|
|
60
|
+
i += 1
|
|
61
|
+
buf = []
|
|
62
|
+
while i < len(s):
|
|
63
|
+
c = s[i]
|
|
64
|
+
if c == q:
|
|
65
|
+
return "".join(buf), i + 1
|
|
66
|
+
if q == '"' and c == "\\":
|
|
67
|
+
nxt = s[i + 1:i + 2]
|
|
68
|
+
if nxt in _ESCAPES:
|
|
69
|
+
buf.append(_ESCAPES[nxt])
|
|
70
|
+
i += 2
|
|
71
|
+
continue
|
|
72
|
+
if nxt in ("u", "U"):
|
|
73
|
+
n = 4 if nxt == "u" else 8
|
|
74
|
+
hexs = s[i + 2:i + 2 + n]
|
|
75
|
+
if not re.fullmatch(r"[0-9A-Fa-f]{%d}" % n, hexs):
|
|
76
|
+
raise ConfigError(where + ": bad \\" + nxt + " escape")
|
|
77
|
+
buf.append(chr(int(hexs, 16)))
|
|
78
|
+
i += 2 + n
|
|
79
|
+
continue
|
|
80
|
+
raise ConfigError(where + ": unknown escape \\" + nxt)
|
|
81
|
+
buf.append(c)
|
|
82
|
+
i += 1
|
|
83
|
+
raise ConfigError(where + ": unterminated string")
|
|
84
|
+
|
|
85
|
+
|
|
86
|
+
def _rest_is_comment(s, i, where):
|
|
87
|
+
rest = s[i:].strip()
|
|
88
|
+
if rest and not rest.startswith("#"):
|
|
89
|
+
raise ConfigError(where + ": unexpected `" + rest + "`")
|
|
90
|
+
|
|
91
|
+
|
|
92
|
+
def parse_subset(text):
|
|
93
|
+
"""The subset of TOML lockmaster.toml uses: `[table]` headers and
|
|
94
|
+
`key = "string"` pairs, keys bare or quoted, comments. Returns nested dicts
|
|
95
|
+
shaped like tomllib's result."""
|
|
96
|
+
data, table = {}, None
|
|
97
|
+
for n, raw in enumerate(text.splitlines(), 1):
|
|
98
|
+
where = "lockmaster.toml line {}".format(n)
|
|
99
|
+
line = raw.strip()
|
|
100
|
+
if not line or line.startswith("#"):
|
|
101
|
+
continue
|
|
102
|
+
if line.startswith("["):
|
|
103
|
+
m = re.match(r"\[\s*([A-Za-z0-9_-]+)\s*\]", line)
|
|
104
|
+
if not m or line.startswith("[["):
|
|
105
|
+
raise ConfigError(where + ": only plain `[table]` headers are in the subset")
|
|
106
|
+
_rest_is_comment(line, m.end(), where)
|
|
107
|
+
name = m.group(1)
|
|
108
|
+
if name in data:
|
|
109
|
+
raise ConfigError(where + ": table [" + name + "] appears twice")
|
|
110
|
+
table = data[name] = {}
|
|
111
|
+
continue
|
|
112
|
+
if table is None:
|
|
113
|
+
raise ConfigError(where + ": a key outside any [table]")
|
|
114
|
+
if line[0] in "\"'":
|
|
115
|
+
key, i = _string(line, 0, where)
|
|
116
|
+
else:
|
|
117
|
+
m = _BARE.match(line)
|
|
118
|
+
if not m:
|
|
119
|
+
raise ConfigError(where + ": cannot read a key here")
|
|
120
|
+
key, i = m.group(0), m.end()
|
|
121
|
+
while i < len(line) and line[i] in " \t":
|
|
122
|
+
i += 1
|
|
123
|
+
if line[i:i + 1] == ".":
|
|
124
|
+
raise ConfigError(where + ": dotted keys are outside the subset")
|
|
125
|
+
if line[i:i + 1] != "=":
|
|
126
|
+
raise ConfigError(where + ": expected `=` after the key")
|
|
127
|
+
i += 1
|
|
128
|
+
while i < len(line) and line[i] in " \t":
|
|
129
|
+
i += 1
|
|
130
|
+
if line[i:i + 1] not in ("\"", "'"):
|
|
131
|
+
raise ConfigError(where + ": only string values are in the subset (got `" + line[i:] + "`)")
|
|
132
|
+
value, i = _string(line, i, where)
|
|
133
|
+
_rest_is_comment(line, i, where)
|
|
134
|
+
if key in table:
|
|
135
|
+
raise ConfigError(where + ": key `" + key + "` appears twice")
|
|
136
|
+
table[key] = value
|
|
137
|
+
return data
|
|
138
|
+
|
|
139
|
+
|
|
140
|
+
# ---------------------------------------------------------------- one validation
|
|
141
|
+
|
|
142
|
+
def parse(text):
|
|
143
|
+
try:
|
|
144
|
+
import tomllib # Python >= 3.11
|
|
145
|
+
except ImportError:
|
|
146
|
+
data = parse_subset(text)
|
|
147
|
+
else:
|
|
148
|
+
try:
|
|
149
|
+
data = tomllib.loads(text)
|
|
150
|
+
except tomllib.TOMLDecodeError as e:
|
|
151
|
+
raise ConfigError("lockmaster.toml: " + str(e))
|
|
152
|
+
# Valid TOML that 3.9 would refuse is refused here too: same file, same result.
|
|
153
|
+
if parse_subset(text) != data:
|
|
154
|
+
raise ConfigError("lockmaster.toml: uses TOML outside lockmaster's subset")
|
|
155
|
+
return validate(data)
|
|
156
|
+
|
|
157
|
+
|
|
158
|
+
def validate(data):
|
|
159
|
+
for name, table in data.items():
|
|
160
|
+
if name not in TABLES:
|
|
161
|
+
raise ConfigError("lockmaster.toml: unknown table [" + name + "]; the tables are "
|
|
162
|
+
+ ", ".join("[" + t + "]" for t in TABLES))
|
|
163
|
+
if not isinstance(table, dict):
|
|
164
|
+
raise ConfigError("lockmaster.toml: `" + name + "` must be a [table]")
|
|
165
|
+
for key, value in table.items():
|
|
166
|
+
if not isinstance(value, str):
|
|
167
|
+
raise ConfigError("lockmaster.toml: [{}] {} is not a string; only strings are in "
|
|
168
|
+
"the subset".format(name, key))
|
|
169
|
+
main = data.get("main", {})
|
|
170
|
+
extra = set(main) - {"branch"}
|
|
171
|
+
if extra:
|
|
172
|
+
raise ConfigError("lockmaster.toml: [main] knows only `branch` (got " + ", ".join(sorted(extra)) + ")")
|
|
173
|
+
merge = data.get("merge", {})
|
|
174
|
+
for path, driver in merge.items():
|
|
175
|
+
if driver not in DRIVERS:
|
|
176
|
+
raise ConfigError("lockmaster.toml: [merge] {} = \"{}\" is no driver; the drivers are {}".format(
|
|
177
|
+
path, driver, ", ".join(DRIVERS)))
|
|
178
|
+
land = data.get("land", {})
|
|
179
|
+
extra = set(land) - {"auto"}
|
|
180
|
+
if extra:
|
|
181
|
+
raise ConfigError("lockmaster.toml: [land] knows only `auto` (got " + ", ".join(sorted(extra)) + ")")
|
|
182
|
+
auto = land.get("auto", "on")
|
|
183
|
+
if auto not in AUTO:
|
|
184
|
+
raise ConfigError("lockmaster.toml: [land] auto = \"{}\"; it is \"on\" or \"off\"".format(auto))
|
|
185
|
+
return Config(main.get("branch", "main"), data.get("checks"), data.get("generated"), merge, auto)
|
|
186
|
+
|
|
187
|
+
|
|
188
|
+
def load(path):
|
|
189
|
+
"""The Config at `path`, or the defaults when there is no file."""
|
|
190
|
+
try:
|
|
191
|
+
with open(str(path), encoding="utf-8") as fh:
|
|
192
|
+
text = fh.read()
|
|
193
|
+
except FileNotFoundError:
|
|
194
|
+
return Config()
|
|
195
|
+
return parse(text)
|
lockmaster/doctor.py
ADDED
|
@@ -0,0 +1,146 @@
|
|
|
1
|
+
"""`lockmaster doctor` — SR-ARCHITECTURE decision 14: stale locks, orphan
|
|
2
|
+
worktrees, unmerged branches, and an installed kit that does not match this
|
|
3
|
+
lockmaster's. Read-only: it says what is wrong and what to type, and fixes nothing.
|
|
4
|
+
"""
|
|
5
|
+
import os
|
|
6
|
+
|
|
7
|
+
from lockmaster import __version__
|
|
8
|
+
from lockmaster import git
|
|
9
|
+
from lockmaster import install as install_mod
|
|
10
|
+
from lockmaster.merge import DRIVERS
|
|
11
|
+
from lockmaster.repo import LANDING, LIVE, WORKTREES_DIR, LandLock
|
|
12
|
+
|
|
13
|
+
|
|
14
|
+
def check(repo):
|
|
15
|
+
"""One line per problem found; empty when all is well."""
|
|
16
|
+
out = []
|
|
17
|
+
lock = LandLock(repo)
|
|
18
|
+
if lock.stale():
|
|
19
|
+
out.append("stale land lock {} (holder {} is not running) — the next `lockmaster run` takes it over".format(
|
|
20
|
+
lock.dir, lock.holder() or "unknown"))
|
|
21
|
+
landing = repo.state / "landing"
|
|
22
|
+
if landing.is_dir():
|
|
23
|
+
for d in sorted(landing.iterdir()):
|
|
24
|
+
out.append("a landing worktree was left behind: {} — `git worktree remove` it once you have "
|
|
25
|
+
"looked".format(d))
|
|
26
|
+
|
|
27
|
+
changes = repo.changes()
|
|
28
|
+
live = [c for c in changes if c["status"] in LIVE]
|
|
29
|
+
for c in live:
|
|
30
|
+
if not os.path.isdir(c["worktree"]):
|
|
31
|
+
out.append("change {} is {} but its worktree {} is gone — `lockmaster abandon --item {}`".format(
|
|
32
|
+
c["item"], c["status"], c["worktree"], c["item"]))
|
|
33
|
+
if c["status"] == LANDING and not lock.dir.is_dir():
|
|
34
|
+
out.append("change {} was landing when its lander stopped — `lockmaster run` queues it again".format(
|
|
35
|
+
c["item"]))
|
|
36
|
+
|
|
37
|
+
from lockmaster import land
|
|
38
|
+
for item, st, paths in land.checkout_overlaps(repo): # W-10 step 3
|
|
39
|
+
out.append("main's checkout has uncommitted edits to {} that change {} ({}) claims — its landing "
|
|
40
|
+
"waits until they are committed elsewhere, saved (`lockmaster save`) or undone".format(
|
|
41
|
+
", ".join(paths), item, st))
|
|
42
|
+
for c in live:
|
|
43
|
+
if c["status"] == "waiting":
|
|
44
|
+
out.append("change {} is waiting: {}".format(c["item"], (c.get("reason") or "").splitlines()[0]))
|
|
45
|
+
|
|
46
|
+
# A landed or abandoned change whose cleanup kept something: kept on purpose, nothing lost.
|
|
47
|
+
kept = [c for c in changes if c["status"] not in LIVE and c.get("cleanup")
|
|
48
|
+
and (os.path.isdir(c["worktree"]) or git.ok(["rev-parse", "--verify", "--quiet",
|
|
49
|
+
"refs/heads/" + c["branch"]], repo.root))]
|
|
50
|
+
for c in kept:
|
|
51
|
+
out.append("change {} is {}, and its cleanup kept: {} — look, then `git worktree remove` and "
|
|
52
|
+
"`git branch -d` by hand, or the decider rules (SR-WORK-CLEANUP decision 3)".format(
|
|
53
|
+
c["item"], c["status"], "; ".join(c["cleanup"])))
|
|
54
|
+
|
|
55
|
+
owned = {os.path.realpath(c["worktree"]) for c in live + kept}
|
|
56
|
+
root_wt = os.path.realpath(str(repo.root / WORKTREES_DIR))
|
|
57
|
+
for t in git.worktrees(repo.root):
|
|
58
|
+
p = os.path.realpath(t["path"])
|
|
59
|
+
if p.startswith(root_wt + os.sep) and p not in owned:
|
|
60
|
+
out.append("orphan worktree {} [{}]: no live change owns it".format(t["path"], t["branch"] or "detached"))
|
|
61
|
+
|
|
62
|
+
live_branches = {c["branch"] for c in live + kept}
|
|
63
|
+
for b in git.out(["for-each-ref", "--format=%(refname:short)", "refs/heads/lm/"], repo.root).split():
|
|
64
|
+
if b in live_branches:
|
|
65
|
+
continue
|
|
66
|
+
if git.ok(["merge-base", "--is-ancestor", b, repo.main], repo.root):
|
|
67
|
+
out.append("branch {} is in {} and no change owns it — `git branch -d {}`".format(b, repo.main, b))
|
|
68
|
+
else:
|
|
69
|
+
out.append("unmerged branch {}: no live change owns it and its commits are not in {} — "
|
|
70
|
+
"the decider rules (SR-WORK-CLEANUP decision 3)".format(b, repo.main))
|
|
71
|
+
|
|
72
|
+
if repo.config.merge:
|
|
73
|
+
missing = [d for d in DRIVERS
|
|
74
|
+
if not git.ok(["config", "--get", "merge.lockmaster-{}.driver".format(d)], repo.root)]
|
|
75
|
+
if missing:
|
|
76
|
+
out.append("merge drivers not registered ({}) — `lockmaster drivers`".format(", ".join(missing)))
|
|
77
|
+
from lockmaster import drivers as drivers_mod
|
|
78
|
+
stale = drivers_mod.stale(repo)
|
|
79
|
+
if stale:
|
|
80
|
+
out.append("merge drivers registered with another machine's paths ({}) — the next landing re-points "
|
|
81
|
+
"them, or `lockmaster drivers`".format(", ".join(stale)))
|
|
82
|
+
|
|
83
|
+
out += kit_problems(repo.root)
|
|
84
|
+
out += work_problems(repo)
|
|
85
|
+
return out
|
|
86
|
+
|
|
87
|
+
|
|
88
|
+
# SR-LAW-10 / W-15: this many landings on main with no WORKLOG.md change reads as drift.
|
|
89
|
+
STALE_LANDINGS = 10
|
|
90
|
+
|
|
91
|
+
|
|
92
|
+
def work_problems(repo):
|
|
93
|
+
"""A missing work file, and a worklog main has moved past (SR-LAW-10). Only
|
|
94
|
+
where the kit is installed; whether a lesson, a machine quirk or a backlog
|
|
95
|
+
row should have been written is judgment, never checked here."""
|
|
96
|
+
if not install_mod.read_lock(repo.root):
|
|
97
|
+
return []
|
|
98
|
+
out = []
|
|
99
|
+
missing = [f for f in install_mod.WORK_FILES if not (repo.root / "work" / f).is_file()]
|
|
100
|
+
if missing:
|
|
101
|
+
out.append("work file{} missing: {} — `lockmaster install {}` creates {} (SR-LAW-10)".format(
|
|
102
|
+
"s" * (len(missing) > 1), ", ".join("work/" + f for f in missing), repo.root,
|
|
103
|
+
"them" if len(missing) > 1 else "it"))
|
|
104
|
+
if "WORKLOG.md" not in missing:
|
|
105
|
+
n = landings_since_worklog(repo)
|
|
106
|
+
if n is not None and n > STALE_LANDINGS:
|
|
107
|
+
out.append("work/WORKLOG.md is stale: {} commits on {} since it last changed (more than {}) — "
|
|
108
|
+
"each session appends an entry (SR-LAW-10)".format(n, repo.main, STALE_LANDINGS))
|
|
109
|
+
return out
|
|
110
|
+
|
|
111
|
+
|
|
112
|
+
def landings_since_worklog(repo):
|
|
113
|
+
"""First-parent commits on main since the last one that changed work/WORKLOG.md;
|
|
114
|
+
None when main has no commit that did."""
|
|
115
|
+
code, last, _ = git.run(["log", "-1", "--format=%H", repo.main, "--", "work/WORKLOG.md"], repo.root, check=False)
|
|
116
|
+
last = last.strip()
|
|
117
|
+
if code != 0 or not last:
|
|
118
|
+
return None
|
|
119
|
+
code, n, _ = git.run(["rev-list", "--count", "--first-parent", last + ".." + repo.main], repo.root, check=False)
|
|
120
|
+
return int(n.strip()) if code == 0 and n.strip().isdigit() else None
|
|
121
|
+
|
|
122
|
+
|
|
123
|
+
def kit_problems(root):
|
|
124
|
+
lock = install_mod.read_lock(root)
|
|
125
|
+
if not lock:
|
|
126
|
+
if (root / ".claude" / "hooks").is_dir():
|
|
127
|
+
return ["the kit in .claude/ was not installed by `lockmaster install` (no {})".format(install_mod.LOCK)]
|
|
128
|
+
return []
|
|
129
|
+
out = []
|
|
130
|
+
try:
|
|
131
|
+
kit = install_mod.find_kit()
|
|
132
|
+
except Exception: # noqa: BLE001 — no kit to compare against is not a problem of the repo
|
|
133
|
+
kit = None
|
|
134
|
+
if lock.get("version") != __version__:
|
|
135
|
+
out.append("installed kit is from lockmaster {}, this is {} — `lockmaster install {}`".format(
|
|
136
|
+
lock.get("version"), __version__, root))
|
|
137
|
+
elif kit is not None and lock.get("kit") != install_mod.kit_digest(kit):
|
|
138
|
+
out.append("installed kit {} differs from this lockmaster's {} — `lockmaster install {}`".format(
|
|
139
|
+
(lock.get("kit") or "?")[:12], install_mod.kit_digest(kit)[:12], root))
|
|
140
|
+
for rel, digest in sorted(lock.get("files", {}).items()):
|
|
141
|
+
f = root / rel
|
|
142
|
+
if not f.is_file():
|
|
143
|
+
out.append("installed file missing: " + rel)
|
|
144
|
+
elif install_mod.sha(f) != digest:
|
|
145
|
+
out.append("installed file edited by hand: {} — edit the kit, then reinstall".format(rel))
|
|
146
|
+
return out
|
lockmaster/drivers.py
ADDED
|
@@ -0,0 +1,93 @@
|
|
|
1
|
+
"""Installing the structural merge drivers — SR-ARCHITECTURE decision 10.
|
|
2
|
+
|
|
3
|
+
Each driver is registered in the repository's `.git/config` as
|
|
4
|
+
`merge.lockmaster-<driver>`, and `.gitattributes` gets one line per
|
|
5
|
+
`lockmaster.toml` `[merge]` path, inside a block lockmaster owns. Usable from
|
|
6
|
+
plain git: `git merge` and `git cherry-pick` call the drivers too.
|
|
7
|
+
"""
|
|
8
|
+
import shlex
|
|
9
|
+
import sys
|
|
10
|
+
from pathlib import Path
|
|
11
|
+
|
|
12
|
+
from lockmaster import git
|
|
13
|
+
from lockmaster.merge import DRIVERS
|
|
14
|
+
|
|
15
|
+
BEGIN = "# lockmaster:begin — written by `lockmaster drivers` from lockmaster.toml [merge]; edit that, not this"
|
|
16
|
+
END = "# lockmaster:end"
|
|
17
|
+
|
|
18
|
+
|
|
19
|
+
def driver_command(name):
|
|
20
|
+
script = Path(__file__).with_name("merge.py").resolve()
|
|
21
|
+
return "{} {} {} %O %A %B %P".format(shlex.quote(sys.executable), shlex.quote(str(script)), name)
|
|
22
|
+
|
|
23
|
+
|
|
24
|
+
def attributes_block(merge):
|
|
25
|
+
lines = [BEGIN]
|
|
26
|
+
for path, driver in merge.items():
|
|
27
|
+
if any(c.isspace() for c in path):
|
|
28
|
+
raise ValueError("a [merge] path with whitespace cannot go into .gitattributes: " + path)
|
|
29
|
+
lines.append("{} merge=lockmaster-{}".format(path, driver))
|
|
30
|
+
return lines + [END]
|
|
31
|
+
|
|
32
|
+
|
|
33
|
+
def write_attributes(root, merge):
|
|
34
|
+
"""Replace lockmaster's block in `.gitattributes` (or add it); everything
|
|
35
|
+
else in the file is kept as it is. Returns True when the file changed."""
|
|
36
|
+
path = Path(root) / ".gitattributes"
|
|
37
|
+
old = path.read_text(encoding="utf-8") if path.exists() else ""
|
|
38
|
+
lines = old.splitlines()
|
|
39
|
+
if BEGIN.split(" —")[0] in "\n".join(lines):
|
|
40
|
+
start = next(i for i, l in enumerate(lines) if l.startswith(BEGIN.split(" —")[0]))
|
|
41
|
+
end = next((i for i in range(start, len(lines)) if lines[i] == END), len(lines) - 1)
|
|
42
|
+
lines = lines[:start] + lines[end + 1:]
|
|
43
|
+
while lines and not lines[-1].strip():
|
|
44
|
+
lines.pop()
|
|
45
|
+
block = attributes_block(merge) if merge else []
|
|
46
|
+
new_lines = lines + ([""] if lines and block else []) + block
|
|
47
|
+
new = "\n".join(new_lines) + ("\n" if new_lines else "")
|
|
48
|
+
if new == old:
|
|
49
|
+
return False
|
|
50
|
+
if new:
|
|
51
|
+
path.write_text(new, encoding="utf-8")
|
|
52
|
+
elif path.exists():
|
|
53
|
+
path.write_text("", encoding="utf-8")
|
|
54
|
+
return True
|
|
55
|
+
|
|
56
|
+
|
|
57
|
+
def stale(repo):
|
|
58
|
+
"""The drivers whose registered command cannot run on this machine — its
|
|
59
|
+
interpreter or merge.py is missing here, as when `install` ran on another
|
|
60
|
+
surface (the Cowork bridge sees the repository at another path). Only
|
|
61
|
+
drivers that are registered count; an unregistered one is `doctor`'s."""
|
|
62
|
+
out = []
|
|
63
|
+
for name in DRIVERS:
|
|
64
|
+
code, cmd, _ = git.run(["config", "--get", "merge.lockmaster-{}.driver".format(name)], repo.root,
|
|
65
|
+
check=False)
|
|
66
|
+
if code != 0:
|
|
67
|
+
continue
|
|
68
|
+
try:
|
|
69
|
+
words = shlex.split(cmd.strip())
|
|
70
|
+
except ValueError:
|
|
71
|
+
words = []
|
|
72
|
+
if len(words) < 2 or not Path(words[0]).exists() or not Path(words[1]).exists():
|
|
73
|
+
out.append(name)
|
|
74
|
+
return out
|
|
75
|
+
|
|
76
|
+
|
|
77
|
+
def heal(repo):
|
|
78
|
+
"""Re-register the drivers when their command points at another machine's
|
|
79
|
+
paths (SR-ARCHITECTURE decision 10). Returns the drivers re-pointed."""
|
|
80
|
+
names = stale(repo)
|
|
81
|
+
for name in names:
|
|
82
|
+
git.run(["config", "merge.lockmaster-{}.driver".format(name), driver_command(name)], repo.root)
|
|
83
|
+
return names
|
|
84
|
+
|
|
85
|
+
|
|
86
|
+
def install(repo):
|
|
87
|
+
"""Register every driver in .git/config; write .gitattributes from [merge]."""
|
|
88
|
+
for name in DRIVERS:
|
|
89
|
+
key = "merge.lockmaster-" + name
|
|
90
|
+
git.run(["config", key + ".name", "lockmaster " + name + " (SR-ARCHITECTURE decision 10)"], repo.root)
|
|
91
|
+
git.run(["config", key + ".driver", driver_command(name)], repo.root)
|
|
92
|
+
changed = write_attributes(repo.root, repo.config.merge)
|
|
93
|
+
return changed
|