@lmctl-ai/lmctl 0.2.71 → 0.2.73
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.
- package/CHANGELOG.md +4 -0
- package/README.md +4 -0
- package/build-info.json +1 -1
- package/dist/cli/index.js +360 -353
- package/examples/git_summary.lms +8 -0
- package/modules/git.lms +167 -0
- package/package.json +3 -2
|
@@ -0,0 +1,8 @@
|
|
|
1
|
+
// Usage: lmscript git_summary.lms [repository-directory]
|
|
2
|
+
// The shipped example resolves its module relative to this file, not cwd.
|
|
3
|
+
import "../modules/git.lms";
|
|
4
|
+
dir = len(ARGV)>0 ? ARGV[0] : Directory;
|
|
5
|
+
state = git.status(dir);
|
|
6
|
+
println(json_stringify({root:git.root(dir),branch:state.branch,
|
|
7
|
+
clean:git.is_clean(dir),staged:state.staged,unstaged:state.unstaged,
|
|
8
|
+
untracked:state.untracked,conflicted:state.conflicted,latest:git.log(dir,3)}));
|
package/modules/git.lms
ADDED
|
@@ -0,0 +1,167 @@
|
|
|
1
|
+
// Git CLI module. Importing declares functions only; it runs no commands.
|
|
2
|
+
// Success values: root/branch/remote_url/rev_parse strings (branch null when
|
|
3
|
+
// detached); status map with path lists and rename maps; log list of maps;
|
|
4
|
+
// diff_stat map {files,added,removed,binary}; is_clean boolean.
|
|
5
|
+
// status uses porcelain v2 -z; staged/unstaged include conflict paths, with a
|
|
6
|
+
// separate conflicted list. Rename maps preserve source and destination.
|
|
7
|
+
// log subject is the full commit message (including body/newlines), minus its
|
|
8
|
+
// terminal LF, rather than Git's flattened %s. n must be 0..10000.
|
|
9
|
+
// diff_stat compares rev to index+worktree, excludes untracked paths and
|
|
10
|
+
// reports renames as delete/add (--no-renames); binary counts are null.
|
|
11
|
+
// Command failures throw via require with argv, exit/signal and stderr tail.
|
|
12
|
+
// Launch errors (e.g. absent git) are loud system errors, with no process exit.
|
|
13
|
+
// argv execution and --end-of-options protect user paths/revisions. Query
|
|
14
|
+
// commands: no explicit fetch/push, no commit, no checkout, no import-time effects.
|
|
15
|
+
// Git configuration/attributes/environment remain trusted; status/diff can run
|
|
16
|
+
// clean filters. Error URLs are redacted; successful remote URLs remain data.
|
|
17
|
+
package git;
|
|
18
|
+
|
|
19
|
+
fn _line(text) {
|
|
20
|
+
if (ends_with(text, "\n")) { return slice(text, 0, len(text)-1); }
|
|
21
|
+
return text;
|
|
22
|
+
}
|
|
23
|
+
fn _redact(text) {
|
|
24
|
+
safe = regex_replace(text, "([a-zA-Z][a-zA-Z0-9+.-]*://)[^\\s/@]+@", "$1<redacted>@", "g");
|
|
25
|
+
return regex_replace(safe, "([?&](?:access_token|token|password|passwd|api_key)=)[^&\\s\"'<>]+", "$1<redacted>", "gi");
|
|
26
|
+
}
|
|
27
|
+
fn _argv(dir, args) {
|
|
28
|
+
require(is_string(dir) && len(dir)>0, "git: directory must be a nonempty string");
|
|
29
|
+
require(index_of(dir, from_char_code(0))<0, "git: directory must not contain NUL");
|
|
30
|
+
for (arg in args) { require(is_string(arg) && index_of(arg, from_char_code(0))<0, "git: arguments must be NUL-free strings"); }
|
|
31
|
+
return concat(["git", "--no-pager", "--no-optional-locks", "-C", abs_path(dir),
|
|
32
|
+
"-c", "color.ui=false", "-c", "core.quotePath=false", "-c", "core.fsmonitor=false"], args);
|
|
33
|
+
}
|
|
34
|
+
fn _checked(argv, result) {
|
|
35
|
+
tail = join(slice(split(_line(result.stderr), "\n"), -12), "\n");
|
|
36
|
+
require(result.code == 0 && !result.timed_out,
|
|
37
|
+
_redact(f"git command {json_stringify(argv)} failed (exit {str(result.code)}, signal {str(result.signal)}): {tail}"));
|
|
38
|
+
return result.stdout;
|
|
39
|
+
}
|
|
40
|
+
fn _run(dir, args) {
|
|
41
|
+
argv = _argv(dir, args);
|
|
42
|
+
result = system(argv);
|
|
43
|
+
return _checked(argv, result);
|
|
44
|
+
}
|
|
45
|
+
fn _fields(record, number) {
|
|
46
|
+
fields = [];
|
|
47
|
+
rest = record;
|
|
48
|
+
while (len(fields) < number) {
|
|
49
|
+
cut = index_of(rest, " ");
|
|
50
|
+
require(cut >= 0, "git: malformed porcelain v2 record");
|
|
51
|
+
push(fields, slice(rest, 0, cut));
|
|
52
|
+
rest = slice(rest, cut+1);
|
|
53
|
+
}
|
|
54
|
+
return {fields: fields, path: rest};
|
|
55
|
+
}
|
|
56
|
+
fn _parse_status(text) {
|
|
57
|
+
result = {branch: null, upstream: null, detached: false, unborn: false,
|
|
58
|
+
ahead: 0, behind: 0, staged: [], unstaged: [], untracked: [], conflicted: [], renamed: []};
|
|
59
|
+
records = split(text, from_char_code(0));
|
|
60
|
+
i = 0;
|
|
61
|
+
head_seen = false;
|
|
62
|
+
oid_seen = false;
|
|
63
|
+
while (i < len(records)) {
|
|
64
|
+
record = records[i];
|
|
65
|
+
if (starts_with(record, "# branch.head ")) {
|
|
66
|
+
head_seen = true;
|
|
67
|
+
name = slice(record, len("# branch.head "));
|
|
68
|
+
result.detached = name == "(detached)";
|
|
69
|
+
if (!result.detached) { result.branch = name; }
|
|
70
|
+
} else if (starts_with(record, "# branch.oid ")) {
|
|
71
|
+
oid_seen = true;
|
|
72
|
+
result.unborn = slice(record, len("# branch.oid ")) == "(initial)";
|
|
73
|
+
} else if (starts_with(record, "# branch.upstream ")) {
|
|
74
|
+
result.upstream = slice(record, len("# branch.upstream "));
|
|
75
|
+
} else if (starts_with(record, "# branch.ab ")) {
|
|
76
|
+
counts = split(slice(record, len("# branch.ab ")), " ");
|
|
77
|
+
require(len(counts)==2, "git: malformed ahead/behind record");
|
|
78
|
+
result.ahead = num(slice(counts[0], 1));
|
|
79
|
+
result.behind = num(slice(counts[1], 1));
|
|
80
|
+
} else if (starts_with(record, "? ")) {
|
|
81
|
+
push(result.untracked, slice(record, 2));
|
|
82
|
+
} else if (starts_with(record, "1 ") || starts_with(record, "2 ") || starts_with(record, "u ")) {
|
|
83
|
+
rename = starts_with(record, "2 ");
|
|
84
|
+
conflict = starts_with(record, "u ");
|
|
85
|
+
number = conflict ? 10 : (rename ? 9 : 8);
|
|
86
|
+
entry = _fields(record, number);
|
|
87
|
+
xy = entry.fields[1];
|
|
88
|
+
if (char_at(xy, 0) != ".") { push(result.staged, entry.path); }
|
|
89
|
+
if (char_at(xy, 1) != ".") { push(result.unstaged, entry.path); }
|
|
90
|
+
if (conflict) { push(result.conflicted, entry.path); }
|
|
91
|
+
if (rename) {
|
|
92
|
+
i = i+1;
|
|
93
|
+
require(i < len(records)-1, "git: missing original path in rename record");
|
|
94
|
+
push(result.renamed, {from: records[i], to: entry.path, status: xy});
|
|
95
|
+
}
|
|
96
|
+
} else {
|
|
97
|
+
require(record=="" || starts_with(record,"# "), "git: unknown porcelain v2 record");
|
|
98
|
+
}
|
|
99
|
+
i = i+1;
|
|
100
|
+
}
|
|
101
|
+
require(head_seen && oid_seen, "git.status: missing porcelain branch headers");
|
|
102
|
+
return result;
|
|
103
|
+
}
|
|
104
|
+
fn status(dir) {
|
|
105
|
+
state = _parse_status(_run(dir, ["status", "--porcelain=v2", "-z", "--branch", "--untracked-files=all", "--renames"]));
|
|
106
|
+
if (state.detached) {
|
|
107
|
+
// The valid branch name (detached) has the same porcelain spelling.
|
|
108
|
+
argv = _argv(dir, ["symbolic-ref", "--quiet", "--short", "HEAD"]);
|
|
109
|
+
result = system(argv);
|
|
110
|
+
if (result.code==0) { state.branch = _line(_checked(argv,result)); state.detached = false; }
|
|
111
|
+
else if (!(result.code==1 && result.stderr=="" && !result.timed_out)) { _checked(argv,result); }
|
|
112
|
+
}
|
|
113
|
+
return state;
|
|
114
|
+
}
|
|
115
|
+
fn root(dir) { return _line(_run(dir, ["rev-parse", "--show-toplevel"])); }
|
|
116
|
+
fn branch(dir) { return status(dir).branch; }
|
|
117
|
+
fn is_clean(dir) {
|
|
118
|
+
state = status(dir);
|
|
119
|
+
return len(state.staged)+len(state.unstaged)+len(state.untracked)+len(state.conflicted)==0;
|
|
120
|
+
}
|
|
121
|
+
fn rev_parse(dir, rev) {
|
|
122
|
+
require(is_string(rev) && len(rev)>0, "git.rev_parse: revision must be a nonempty string");
|
|
123
|
+
return _line(_run(dir, ["rev-parse", "--verify", "--end-of-options", rev]));
|
|
124
|
+
}
|
|
125
|
+
fn remote_url(dir) { return _line(_run(dir, ["remote", "get-url", "--", "origin"])); }
|
|
126
|
+
fn log(dir, n) {
|
|
127
|
+
require(is_number(n) && n>=0 && n<=10000 && n==round(n), "git.log: n must be an integer from 0 to 10000");
|
|
128
|
+
// Verify HEAD without scanning the worktree or invoking its clean filters.
|
|
129
|
+
argv = _argv(dir, ["rev-parse", "--verify", "--quiet", "--end-of-options", "HEAD"]);
|
|
130
|
+
head = system(argv);
|
|
131
|
+
if (head.code==1 && head.stderr=="" && !head.timed_out) { return []; }
|
|
132
|
+
_checked(argv, head);
|
|
133
|
+
if (n==0) { return []; }
|
|
134
|
+
text = _run(dir, ["log", "--no-show-signature", "--encoding=UTF-8", "-n", str(n), "--format=%H%x00%an%x00%aI%x00%B", "-z", "--"]);
|
|
135
|
+
fields = split(text, from_char_code(0));
|
|
136
|
+
require(len(fields)>1 && (len(fields)-1)%4==0, "git: malformed NUL log records");
|
|
137
|
+
entries = [];
|
|
138
|
+
i = 0;
|
|
139
|
+
while (i < len(fields)-1) {
|
|
140
|
+
date = fields[i+2];
|
|
141
|
+
// Newer git prints UTC as Z, older git as +00:00: one stable spelling for every git version.
|
|
142
|
+
if (ends_with(date, "Z")) { date = slice(date, 0, len(date)-1) + "+00:00"; }
|
|
143
|
+
push(entries, {hash: fields[i], author: fields[i+1], date: date, subject: _line(fields[i+3])});
|
|
144
|
+
i = i+4;
|
|
145
|
+
}
|
|
146
|
+
return entries;
|
|
147
|
+
}
|
|
148
|
+
fn diff_stat(dir, rev) {
|
|
149
|
+
hash = rev_parse(dir, rev);
|
|
150
|
+
text = _run(dir, ["diff", "--numstat", "-z", "--no-renames", "--no-ext-diff", "--no-textconv", hash, "--"]);
|
|
151
|
+
result = {files: [], added: 0, removed: 0, binary: 0};
|
|
152
|
+
for (record in split(text, from_char_code(0))) {
|
|
153
|
+
if (record != "") {
|
|
154
|
+
first = index_of(record, "\t");
|
|
155
|
+
second = index_of(record, "\t", first+1);
|
|
156
|
+
require(first>=0 && second>first, "git: malformed numstat record");
|
|
157
|
+
added = slice(record, 0, first);
|
|
158
|
+
removed = slice(record, first+1, second);
|
|
159
|
+
binary = added=="-" && removed=="-";
|
|
160
|
+
path = slice(record, second+1);
|
|
161
|
+
push(result.files, {path: path, added: binary ? null : num(added), removed: binary ? null : num(removed), binary: binary});
|
|
162
|
+
if (binary) { result.binary = result.binary+1; }
|
|
163
|
+
else { result.added = result.added+num(added); result.removed = result.removed+num(removed); }
|
|
164
|
+
}
|
|
165
|
+
}
|
|
166
|
+
return result;
|
|
167
|
+
}
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@lmctl-ai/lmctl",
|
|
3
|
-
"version": "0.2.
|
|
3
|
+
"version": "0.2.73",
|
|
4
4
|
"description": "A provider-agnostic control plane for teams of AI coding agents — across providers, with independent review and durable memory.",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"homepage": "https://lmctl.com",
|
|
@@ -33,7 +33,8 @@
|
|
|
33
33
|
"THIRD_PARTY_NOTICES.md",
|
|
34
34
|
"README.md",
|
|
35
35
|
"CHANGELOG.md",
|
|
36
|
-
"examples/"
|
|
36
|
+
"examples/",
|
|
37
|
+
"modules/git.lms"
|
|
37
38
|
],
|
|
38
39
|
"engines": {
|
|
39
40
|
"node": ">=24.15.0"
|