monisess 0.0.0-stage → 0.1.0

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.
@@ -0,0 +1,128 @@
1
+ /**
2
+ * Минимальный фронтматтер без YAML-зависимости.
3
+ *
4
+ * Поддерживаются только строки и списки строк: этого хватает для записей памяти,
5
+ * а полноценный YAML тащил бы парсер тяжелее всего сервера. Файлы должны оставаться
6
+ * читаемыми и правимыми руками — поэтому формат «ключ: значение», а не JSON.
7
+ */
8
+ export function parse(text) {
9
+ if (!text.startsWith("---\n"))
10
+ return { meta: {}, body: text };
11
+ const end = text.indexOf("\n---\n", 4);
12
+ if (end < 0)
13
+ return { meta: {}, body: text };
14
+ const head = text.slice(4, end);
15
+ const body = text.slice(end + 5);
16
+ const meta = {};
17
+ // Ключ без значения открывает список; если элементов не последовало — это пустая строка.
18
+ let listKey = null;
19
+ const opened = new Set();
20
+ for (const raw of head.split("\n")) {
21
+ const line = raw.replace(/\s+$/, "");
22
+ if (!line)
23
+ continue;
24
+ const item = /^\s*-\s+(.*)$/.exec(line);
25
+ if (item && listKey) {
26
+ meta[listKey].push(unquote(item[1]));
27
+ continue;
28
+ }
29
+ const kv = /^([A-Za-z_][\w-]*):\s*(.*)$/.exec(line);
30
+ if (!kv)
31
+ continue;
32
+ const [, key, value] = kv;
33
+ listKey = null;
34
+ if (value === "") {
35
+ meta[key] = [];
36
+ listKey = key;
37
+ opened.add(key);
38
+ }
39
+ else if (value.startsWith("[") && value.endsWith("]")) {
40
+ meta[key] = splitInline(value.slice(1, -1));
41
+ }
42
+ else {
43
+ meta[key] = unquote(value);
44
+ }
45
+ }
46
+ for (const key of opened) {
47
+ if (meta[key].length === 0)
48
+ meta[key] = "";
49
+ }
50
+ return { meta, body };
51
+ }
52
+ function splitInline(inner) {
53
+ if (!inner.trim())
54
+ return [];
55
+ const out = [];
56
+ let cur = "";
57
+ let quote = null;
58
+ for (const ch of inner) {
59
+ if (quote) {
60
+ if (ch === quote)
61
+ quote = null;
62
+ else
63
+ cur += ch;
64
+ }
65
+ else if (ch === '"' || ch === "'")
66
+ quote = ch;
67
+ else if (ch === ",") {
68
+ out.push(cur.trim());
69
+ cur = "";
70
+ }
71
+ else
72
+ cur += ch;
73
+ }
74
+ if (cur.trim())
75
+ out.push(cur.trim());
76
+ return out;
77
+ }
78
+ function unquote(s) {
79
+ const t = s.trim();
80
+ if (t.length >= 2 && t.startsWith('"') && t.endsWith('"')) {
81
+ // Записывали через JSON.stringify — читаем так же, чтобы не потерять экранирование.
82
+ try {
83
+ return JSON.parse(t);
84
+ }
85
+ catch {
86
+ return t.slice(1, -1);
87
+ }
88
+ }
89
+ if (t.length >= 2 && t.startsWith("'") && t.endsWith("'"))
90
+ return t.slice(1, -1);
91
+ return t;
92
+ }
93
+ function quote(s) {
94
+ // Кавычки нужны только там, где значение иначе прочитается как список или сломает строку.
95
+ return /[\[\]#:,\n"']|^\s|\s$/.test(s) || s === "" ? JSON.stringify(s) : s;
96
+ }
97
+ export function serialize(doc) {
98
+ const lines = ["---"];
99
+ for (const [key, value] of Object.entries(doc.meta)) {
100
+ if (Array.isArray(value)) {
101
+ if (value.length === 0)
102
+ lines.push(`${key}: []`);
103
+ else if (value.every((v) => v.length < 40 && !/[,\[\]"'\n]/.test(v)))
104
+ lines.push(`${key}: [${value.join(", ")}]`);
105
+ else {
106
+ lines.push(`${key}:`);
107
+ for (const v of value)
108
+ lines.push(` - ${quote(v)}`);
109
+ }
110
+ }
111
+ else {
112
+ lines.push(`${key}: ${quote(value)}`);
113
+ }
114
+ }
115
+ lines.push("---");
116
+ const body = doc.body.replace(/^\n+/, "").replace(/\s+$/, "");
117
+ return lines.join("\n") + "\n" + body + "\n";
118
+ }
119
+ export function str(meta, key) {
120
+ const v = meta[key];
121
+ return Array.isArray(v) ? v.join(", ") : (v ?? "");
122
+ }
123
+ export function list(meta, key) {
124
+ const v = meta[key];
125
+ if (Array.isArray(v))
126
+ return v;
127
+ return v ? [v] : [];
128
+ }
package/dist/git.js ADDED
@@ -0,0 +1,84 @@
1
+ import { execFileSync } from "node:child_process";
2
+ function git(cwd, args) {
3
+ try {
4
+ return execFileSync("git", args, { cwd, stdio: ["ignore", "pipe", "ignore"], maxBuffer: 8 * 1024 * 1024 })
5
+ .toString()
6
+ .replace(/\n$/, "");
7
+ }
8
+ catch {
9
+ return null;
10
+ }
11
+ }
12
+ export function gitState(cwd) {
13
+ const head = git(cwd, ["rev-parse", "--short", "HEAD"]);
14
+ if (head === null)
15
+ return { available: false, branch: "", head: "", dirty: [] };
16
+ const branch = git(cwd, ["rev-parse", "--abbrev-ref", "HEAD"]) ?? "";
17
+ const status = git(cwd, ["status", "--porcelain"]) ?? "";
18
+ return { available: true, branch, head, dirty: status ? status.split("\n") : [] };
19
+ }
20
+ /**
21
+ * Коммиты после `since` (исключительно). Пустой since — последние `limit`.
22
+ * `excludeMemory` отбрасывает коммиты, которые тронули только `.monisess/`:
23
+ * запись задачи с панели — не та работа, о которой стоит напоминать «без памяти».
24
+ */
25
+ export function commitsSince(cwd, since, limit = 50, excludeMemory = false) {
26
+ const range = since ? `${since}..HEAD` : "HEAD";
27
+ const scope = excludeMemory ? ["--", ".", ":(exclude).monisess"] : [];
28
+ const out = git(cwd, ["log", `--max-count=${limit}`, "--format=%h%x09%as%x09%s", range, ...scope]);
29
+ if (out === null)
30
+ return null;
31
+ if (!out)
32
+ return [];
33
+ return out.split("\n").map((line) => {
34
+ const [hash, date, ...rest] = line.split("\t");
35
+ return { hash, date, subject: rest.join("\t") };
36
+ });
37
+ }
38
+ /** Файлы, тронутые в диапазоне коммитов плюс незакоммиченные. */
39
+ export function filesSince(cwd, since) {
40
+ const set = new Set();
41
+ if (since) {
42
+ const out = git(cwd, ["diff", "--name-only", `${since}..HEAD`]);
43
+ for (const f of (out ?? "").split("\n"))
44
+ if (f)
45
+ set.add(f);
46
+ }
47
+ const status = git(cwd, ["status", "--porcelain"]) ?? "";
48
+ for (const line of status.split("\n")) {
49
+ const f = line.slice(3).trim();
50
+ if (f)
51
+ set.add(f.replace(/^.* -> /, ""));
52
+ }
53
+ return [...set].sort();
54
+ }
55
+ /** Даты коммитов (по одной строке на коммит) за последние `days` дней — для карты активности. */
56
+ export function commitDates(cwd, days) {
57
+ const out = git(cwd, ["log", `--since=${days} days ago`, "--format=%as", "--max-count=2000"]);
58
+ return out ? out.split("\n").filter(Boolean) : [];
59
+ }
60
+ /**
61
+ * Коммиты, в сообщении которых упомянут номер задачи.
62
+ *
63
+ * Одним проходом по истории, а не поиском на каждую задачу: проектов несколько,
64
+ * задач полсотни, и полсотни вызовов git на экран — это ощутимая пауза.
65
+ */
66
+ export function commitsByTask(cwd, limit = 500) {
67
+ const out = git(cwd, ["log", `--max-count=${limit}`, "--format=%h%x09%as%x09%s"]);
68
+ const map = {};
69
+ if (!out)
70
+ return map;
71
+ for (const line of out.split("\n")) {
72
+ const [hash, date, ...rest] = line.split("\t");
73
+ const subject = rest.join("\t");
74
+ const ids = subject.match(/\bT\d{3,}\b/g);
75
+ if (!ids)
76
+ continue;
77
+ for (const id of new Set(ids))
78
+ (map[id] ??= []).push({ hash, date, subject });
79
+ }
80
+ return map;
81
+ }
82
+ export function commitExists(cwd, hash) {
83
+ return hash !== "" && git(cwd, ["cat-file", "-e", `${hash}^{commit}`]) !== null;
84
+ }