@anhvupt/tito 0.1.3 → 0.2.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.
package/README.md CHANGED
@@ -3,12 +3,13 @@
3
3
  **Quiet orchestration. Trusted continuity.**
4
4
 
5
5
  Tito is a chat-first engineering coordinator for solo builders. You talk to one
6
- coordinator; Tito explores the repository, chooses the right working mode,
7
- turns larger work into reviewable slices, and keeps implementation inside the
8
- scope you approved.
6
+ coordinator. Tito confirms what you want, writes the plan as the spec, and
7
+ sends only the approved slice to a sub-agent.
9
8
 
10
- It is local-first, Git-friendly, and designed to add coordination without
11
- replacing your project documentation, rules, or specialists.
9
+ The spec records the decisions and the conventions behind them, the test cases
10
+ you review before they are written, and the docs that must change. Tito is
11
+ local-first and Git-friendly. It coordinates your documentation, rules, and
12
+ specialists, and it updates the docs named in the plan before a pull request.
12
13
 
13
14
  ## What Tito helps with
14
15
 
@@ -17,9 +18,16 @@ replacing your project documentation, rules, or specialists.
17
18
  - **The right amount of process:** Tito recommends Ask for discovery, Plan for
18
19
  uncertain or critical work, and Agent only for an approved implementation
19
20
  slice.
20
- - **Plans that make decisions:** the planner owns technical choices, records
21
- important trade-offs, and includes signatures, schemas, pseudocode, or
22
- guidance code when that makes implementation clearer.
21
+ - **Plans that make decisions:** Tito confirms the goal and what is out of
22
+ scope, then asks one batch of clarifying questions, lighter on `solo-fast`.
23
+ One obvious reading continues after a one-line confirmation. Tito still asks
24
+ before locking a technical decision or a product-vision change. The plan
25
+ records that decision and includes signatures, schemas, pseudocode, or
26
+ guidance code when that makes implementation clearer. Decisions name the
27
+ convention they follow. Test cases carry a layer tag: `[unit]`,
28
+ `[integration]`, or `[e2e]`. The plan also lists its docs impact.
29
+ - **A light joke, sometimes:** after `Hola, Tito here!`, Tito may add one short
30
+ joke. It does not replace the answer, and it stays out of the CLI.
23
31
  - **Smaller reviews:** work is split into bounded slices with acceptance
24
32
  criteria, tests, forbidden changes, risks, and a stop condition.
25
33
  - **Safer changes:** Tito preserves uncommitted work, refuses silent overwrites,
@@ -52,11 +60,15 @@ mode decides whether it may change anything:
52
60
  | Tech docs writer | — | Update technical documentation |
53
61
  | User docs writer | — | Update user documentation |
54
62
 
55
- Several read-only specialists may work together. Only one mutating mode may be
56
- active. Backend and frontend work is split into sequential slices. After a
57
- module is finished, Tito schedules the tech docs writer and then the user docs
58
- writer, one at a time, before calling that module done. Skip that handoff only
59
- when you explicitly waive it for that module. Each specialist carries triggers,
63
+ Several read-only specialists may work together. Coding sub-agents follow the
64
+ profile cap: `client-careful` 3, `solo-balanced` 6, and `solo-fast` 12. Each
65
+ has its own plan and branch. Tito does not code in the chat. Every change,
66
+ including a small one, goes to a sub-agent, and Tito returns to the user.
67
+ Backend and frontend work can run together inside that cap. Docs named in
68
+ the plan are updated, or you waive them, before Tito offers a pull request.
69
+ After a module is finished, Tito still schedules the tech docs writer and then
70
+ the user docs writer, one at a time, before calling that module done. Skip
71
+ that handoff only when you explicitly waive it for that module. Each specialist carries triggers,
60
72
  knowledge references, handoffs, and a stop condition. The detailed prompt stays
61
73
  lean and loads knowledge only when that mode needs it.
62
74
 
@@ -103,10 +115,11 @@ npx tito upgrade --confirm
103
115
  agents, skills, and the Tito section of `AGENTS.md`. Consumer rules, consumer
104
116
  agents, and other project skills stay untouched.
105
117
 
106
- Tito is installed in each project. It is not a required global command, and
107
- there is no global configuration yet. A future personal default would be
108
- overridden by that project's `tito.yaml`. The safety floor still cannot be
109
- weakened.
118
+ Tito is installed in each project. It is not a required global command.
119
+ An opt-in local admin index (`tito admin add|list|remove|refresh`) can register
120
+ repos under `~/.config/tito/admin/` and store recent commit subjects, dates, and
121
+ touched paths — never diffs. A project `tito.yaml` still overrides any personal
122
+ default. The safety floor cannot be weakened.
110
123
 
111
124
  ## What works today
112
125
 
@@ -142,6 +155,11 @@ node dist/cli.js inspect
142
155
  node dist/cli.js inspect --root .
143
156
  node dist/cli.js apply --dry-run --profile solo-balanced
144
157
  node dist/cli.js init --profile solo-balanced
158
+ node dist/cli.js admin add
159
+ node dist/cli.js admin list
160
+ node dist/cli.js admin refresh
161
+ node dist/cli.js admin remove --root /path/to/repo
162
+ node dist/cli.js admin retro --week 2026-W40
145
163
  ```
146
164
 
147
165
  `inspect` reads `tito.yaml` when it exists and checks whether `AGENTS.md` is
@@ -155,6 +173,15 @@ specialist agent files. It writes those files only with `--confirm`, and it
155
173
  refuses when an existing file would be overwritten. In chat, `/tito-init` runs
156
174
  this same command.
157
175
 
176
+ `admin` is opt-in. `add` registers the current repo (or `--root`), `list` prints
177
+ registered paths, `remove` drops one path, and `refresh` writes local branch plus
178
+ the last 50 commit subjects, dates, and file paths (secrets like `.env` skipped;
179
+ no diffs) for chat and CLI to share.
180
+
181
+ `retro` builds that week's report from local git and saves `retros/<week>.json`
182
+ and `retros/<week>.md` under the admin root. Review-fix counts, docs-blank, and
183
+ merge duration say unavailable until GitHub data is added.
184
+
158
185
  ## How work moves
159
186
 
160
187
  The normal careful path is:
@@ -165,6 +192,39 @@ A small, obvious change can move from discovery directly to an explicitly
165
192
  approved slice. Review feedback can return an in-scope slice to implementation.
166
193
  Other lifecycle skips are rejected.
167
194
 
195
+ ## Git flow
196
+
197
+ Tito suggests the branch before checkout, for example `Suggested branch: feat/short-slug from develop`. You accept it or name another base or type. The base is `dev`, `develop`, `main`, or `master`. `dev` and `develop` are interchangeable. `main` and `master` are interchangeable. The type is `feat`, `fix`, `hot-fix`, `chores`, `refactor`, or `debug`.
198
+
199
+ `tito.yaml` may set the default base:
200
+
201
+ ```yaml
202
+ schemaVersion: 1
203
+ profile: client-careful
204
+ git:
205
+ defaultBase: develop
206
+ ```
207
+
208
+ `product.screenLanguage` is optional and accepts only `vi` or `en`. A missing product block is valid. A missing `screenLanguage` is valid. Unknown product fields are rejected. `product.tenancy` is optional (`single` or `multi`). `product.surfaces` is an optional list of `{ id }` entries. Missing `tenancy` and missing `surfaces` stay valid.
209
+
210
+ ```yaml
211
+ product:
212
+ screenLanguage: vi
213
+ tenancy: single
214
+ surfaces:
215
+ - id: admin
216
+ ```
217
+
218
+ - Code stays English.
219
+ - In a Vietnamese app (`screenLanguage: vi`), routes and slugs are Vietnamese first. The public path is native Vietnamese, for example `/tien-ich/ca-phe`. Do not invent that Vietnamese by translating an English slug word for word. If the product is bilingual, the English route comes second.
220
+ - An English app (`screenLanguage: en`) keeps English routes and slugs.
221
+ - In a Vietnamese app (`screenLanguage: vi`), every string a person reads is Vietnamese: tables, labels, buttons, headings, and messages. Write native Vietnamese first. Do not invent it by translating English word for word. English may exist as a second field only when the product is bilingual, and it comes after the Vietnamese.
222
+ - An English app (`screenLanguage: en`) stays English on screen.
223
+ - When `screenLanguage` is missing and the screen language is unclear, Tito asks once. One obvious reading continues without a question.
224
+ - Globalized apps store timestamps in UTC and show them in the user's timezone. There is no switch to turn that off.
225
+
226
+ A commit subject is `<type>: <sentence>`. The type is the same token as the branch type: `feat`, `fix`, `hot-fix`, `chores`, `refactor`, or `debug`. Examples: `feat: add commit message rule`, `fix: reject a duplicate surface id`, `chores: record the commit prefix rule`, and `hot-fix: stop a bad release build`. The words after the colon are one finished sentence of at most 70 words. The type is not counted in those 70 words. The body is a separate description. When you review a plan, Tito saves that plan and your edit as separate files under `.tito/feedback/<slug>/`. `review.md` starts with front matter `outcome: accepted | edited | expanded`. Tito indexes only the plan name. The plan body stays in Cursor's plan file. After an approved slice is coded, every review fix goes into one plan named `review/<slug>` on the same branch. Tito asks before opening a pull request only after that plan is coded, or when you accept the code with no changes. The pull request description has four parts within 2 to 50 lines: a one-line problem, what changed, review fixes, and checks for lint, code quality, conventions, tests, build, and docs. Init creates `.github/pull_request_template.md` from Tito's template when it is missing. Upgrade replaces that file with Tito's template. Tito does not approve a pull request. Tito merges only when you call for the merge and the pull request already has an approval. After a pull request is merged, Tito asks before the next slice. Tito switches back to the base branch only when you say so clearly. Tito pushes directly to the base branch only when you clearly instruct that push.
227
+
168
228
  ## Development
169
229
 
170
230
  Requirements:
package/dist/cli.js CHANGED
@@ -9,6 +9,8 @@ import { InspectionError, formatInspection, inspectRepository, } from "./core/in
9
9
  import { PlanError, formatAdoptionPlan, planAdoption } from "./core/plan.js";
10
10
  import { InitError, applyInitialization, formatInitialization, planInitialization, promptForProfile, } from "./core/init.js";
11
11
  import { applyUpgrade, formatUpgrade, planUpgrade, titoOwnedFiles, } from "./core/upgrade.js";
12
+ import { AdminError, addRepo, formatContexts, formatRepos, listRepos, refreshRepos, removeRepo, resolveAdminRoot, } from "./core/admin.js";
13
+ import { RetroError, buildRetro, formatRetro, retroJson, saveRetro, } from "./core/retro.js";
12
14
  const packageJson = JSON.parse(readFileSync(new URL("../package.json", import.meta.url), "utf8"));
13
15
  const help = `Tito — quiet orchestration for solo builders
14
16
 
@@ -18,6 +20,11 @@ Usage:
18
20
  tito apply --dry-run --profile <id> [--root <path>]
19
21
  tito init [--profile <id>] [--root <path>] [--confirm]
20
22
  tito upgrade [--root <path>] [--confirm]
23
+ tito admin add [--root <path>] [--admin-root <path>]
24
+ tito admin list [--admin-root <path>]
25
+ tito admin remove --root <path> [--admin-root <path>]
26
+ tito admin refresh [--admin-root <path>]
27
+ tito admin retro [--week <YYYY-Www>] [--timezone <IANA>] [--json] [--alerts] [--admin-root <path>]
21
28
 
22
29
  Options:
23
30
  -h, --help Show help
@@ -28,6 +35,7 @@ Commands:
28
35
  apply Dry-run an adoption plan. Writes are not available.
29
36
  init Install Tito files and specialist agents. Confirm before writing.
30
37
  upgrade Install the latest Tito and replace Tito-owned files only.
38
+ admin Opt-in local repo index: add, list, remove, refresh, retro.
31
39
  `;
32
40
  function fail(message) {
33
41
  process.stderr.write(`${message}\n`);
@@ -238,6 +246,130 @@ function isTitoSource(root) {
238
246
  return false;
239
247
  }
240
248
  }
249
+ function runAdmin(args) {
250
+ if (args.includes("-h") || args.includes("--help") || args.length === 0) {
251
+ process.stdout.write(help);
252
+ return;
253
+ }
254
+ const action = args[0];
255
+ const rest = args.slice(1);
256
+ if (action === "retro") {
257
+ runRetro(rest);
258
+ return;
259
+ }
260
+ if (action !== "add" &&
261
+ action !== "list" &&
262
+ action !== "remove" &&
263
+ action !== "refresh") {
264
+ fail(`Unknown admin action: ${action}`);
265
+ return;
266
+ }
267
+ let root = process.cwd();
268
+ let adminRoot = resolveAdminRoot();
269
+ let rootProvided = false;
270
+ try {
271
+ const { values } = parseArgs({
272
+ args: rest,
273
+ options: {
274
+ root: { type: "string" },
275
+ "admin-root": { type: "string" },
276
+ },
277
+ strict: true,
278
+ allowPositionals: false,
279
+ });
280
+ if (values.root !== undefined) {
281
+ root = values.root;
282
+ rootProvided = true;
283
+ }
284
+ if (values["admin-root"] !== undefined) {
285
+ adminRoot = resolveAdminRoot(values["admin-root"]);
286
+ }
287
+ }
288
+ catch (error) {
289
+ fail(error instanceof Error ? error.message : "Invalid admin arguments.");
290
+ return;
291
+ }
292
+ try {
293
+ if (action === "add") {
294
+ const result = addRepo(adminRoot, root);
295
+ process.stdout.write(result.created
296
+ ? `added: ${result.repo.path}\n`
297
+ : `kept: ${result.repo.path}\n`);
298
+ return;
299
+ }
300
+ if (action === "list") {
301
+ process.stdout.write(formatRepos(listRepos(adminRoot)));
302
+ return;
303
+ }
304
+ if (action === "remove") {
305
+ if (!rootProvided) {
306
+ fail("Missing required option --root.");
307
+ return;
308
+ }
309
+ const removed = removeRepo(adminRoot, root);
310
+ process.stdout.write(`removed: ${removed.path}\n`);
311
+ return;
312
+ }
313
+ process.stdout.write(formatContexts(refreshRepos(adminRoot)));
314
+ }
315
+ catch (error) {
316
+ if (error instanceof AdminError) {
317
+ fail(`${error.code}: ${error.message}`);
318
+ return;
319
+ }
320
+ throw error;
321
+ }
322
+ }
323
+ function runRetro(args) {
324
+ let week;
325
+ let timezone;
326
+ let json = false;
327
+ let alerts = false;
328
+ let adminRoot = resolveAdminRoot();
329
+ try {
330
+ const { values } = parseArgs({
331
+ args,
332
+ options: {
333
+ week: { type: "string" },
334
+ timezone: { type: "string" },
335
+ json: { type: "boolean" },
336
+ alerts: { type: "boolean" },
337
+ "admin-root": { type: "string" },
338
+ },
339
+ strict: true,
340
+ allowPositionals: false,
341
+ });
342
+ if (values.week !== undefined)
343
+ week = values.week;
344
+ if (values.timezone !== undefined)
345
+ timezone = values.timezone;
346
+ json = values.json === true;
347
+ alerts = values.alerts === true;
348
+ if (values["admin-root"] !== undefined) {
349
+ adminRoot = resolveAdminRoot(values["admin-root"]);
350
+ }
351
+ }
352
+ catch (error) {
353
+ fail(error instanceof Error ? error.message : "Invalid admin arguments.");
354
+ return;
355
+ }
356
+ try {
357
+ const report = buildRetro({
358
+ adminRoot,
359
+ ...(week !== undefined ? { week } : {}),
360
+ ...(timezone !== undefined ? { timezone } : {}),
361
+ });
362
+ saveRetro(adminRoot, report);
363
+ process.stdout.write(json ? retroJson(report) : formatRetro(report, { alerts }));
364
+ }
365
+ catch (error) {
366
+ if (error instanceof RetroError || error instanceof AdminError) {
367
+ fail(`${error.code}: ${error.message}`);
368
+ return;
369
+ }
370
+ throw error;
371
+ }
372
+ }
241
373
  async function run(args) {
242
374
  if (args[0] === "inspect") {
243
375
  runInspect(args.slice(1));
@@ -253,6 +385,10 @@ async function run(args) {
253
385
  if (args[0] === "upgrade") {
254
386
  return runUpgrade(args.slice(1));
255
387
  }
388
+ if (args[0] === "admin") {
389
+ runAdmin(args.slice(1));
390
+ return;
391
+ }
256
392
  let helpRequested = false;
257
393
  let versionRequested = false;
258
394
  try {
@@ -0,0 +1,241 @@
1
+ import { spawnSync } from "node:child_process";
2
+ import { existsSync, mkdirSync, readFileSync, renameSync, statSync, writeFileSync, } from "node:fs";
3
+ import { homedir } from "node:os";
4
+ import { basename, dirname, join, resolve } from "node:path";
5
+ export class AdminError extends Error {
6
+ code;
7
+ path;
8
+ constructor(code, path, message) {
9
+ super(message);
10
+ this.name = "AdminError";
11
+ this.code = code;
12
+ this.path = path;
13
+ }
14
+ }
15
+ const SECRET_BASENAMES = new Set([".env", "credentials.json", "id_rsa"]);
16
+ export function isSecretFilename(filePath) {
17
+ const name = basename(filePath);
18
+ if (SECRET_BASENAMES.has(name))
19
+ return true;
20
+ return name.startsWith(".env.");
21
+ }
22
+ export function resolveAdminRoot(explicit) {
23
+ if (explicit !== undefined)
24
+ return resolve(explicit);
25
+ return join(homedir(), ".config", "tito", "admin");
26
+ }
27
+ export function reposFilePath(adminRoot) {
28
+ return join(resolve(adminRoot), "repos.json");
29
+ }
30
+ export function contextFilePath(adminRoot) {
31
+ return join(resolve(adminRoot), "context.json");
32
+ }
33
+ function errno(error) {
34
+ if (typeof error === "object" &&
35
+ error !== null &&
36
+ "code" in error &&
37
+ typeof error.code === "string") {
38
+ return error.code;
39
+ }
40
+ return null;
41
+ }
42
+ function assertDirectory(path) {
43
+ const resolved = resolve(path);
44
+ try {
45
+ if (!statSync(resolved).isDirectory()) {
46
+ throw new AdminError("not-a-directory", resolved, `${resolved} is not a directory.`);
47
+ }
48
+ }
49
+ catch (error) {
50
+ if (error instanceof AdminError)
51
+ throw error;
52
+ const code = errno(error);
53
+ if (code === "ENOENT") {
54
+ throw new AdminError("not-a-directory", resolved, `${resolved} is not a directory.`);
55
+ }
56
+ throw new AdminError("filesystem", resolved, `Cannot inspect ${resolved}.`);
57
+ }
58
+ return resolved;
59
+ }
60
+ function writeJsonAtomic(path, value) {
61
+ mkdirSync(dirname(path), { recursive: true });
62
+ const tmp = `${path}.${process.pid}.tmp`;
63
+ try {
64
+ writeFileSync(tmp, `${JSON.stringify(value, null, 2)}\n`, "utf8");
65
+ renameSync(tmp, path);
66
+ }
67
+ catch (error) {
68
+ throw new AdminError("filesystem", path, error instanceof Error ? error.message : `Cannot write ${path}.`);
69
+ }
70
+ }
71
+ function readJsonFile(path) {
72
+ try {
73
+ return JSON.parse(readFileSync(path, "utf8"));
74
+ }
75
+ catch (error) {
76
+ const code = errno(error);
77
+ if (code === "ENOENT")
78
+ return null;
79
+ throw new AdminError("filesystem", path, error instanceof Error ? error.message : `Cannot read ${path}.`);
80
+ }
81
+ }
82
+ function isAdminRepo(value) {
83
+ if (typeof value !== "object" || value === null)
84
+ return false;
85
+ const record = value;
86
+ return typeof record.path === "string" && typeof record.addedAt === "string";
87
+ }
88
+ export function loadRepos(adminRoot) {
89
+ const path = reposFilePath(adminRoot);
90
+ if (!existsSync(path))
91
+ return [];
92
+ const raw = readJsonFile(path);
93
+ if (!Array.isArray(raw)) {
94
+ throw new AdminError("filesystem", path, `${path} must contain a JSON array.`);
95
+ }
96
+ const repos = [];
97
+ for (const item of raw) {
98
+ if (!isAdminRepo(item)) {
99
+ throw new AdminError("filesystem", path, `${path} has an invalid repo entry.`);
100
+ }
101
+ repos.push({ path: item.path, addedAt: item.addedAt });
102
+ }
103
+ return repos;
104
+ }
105
+ export function saveRepos(adminRoot, repos) {
106
+ writeJsonAtomic(reposFilePath(adminRoot), repos);
107
+ }
108
+ export function loadContexts(adminRoot) {
109
+ const path = contextFilePath(adminRoot);
110
+ if (!existsSync(path))
111
+ return [];
112
+ const raw = readJsonFile(path);
113
+ if (!Array.isArray(raw)) {
114
+ throw new AdminError("filesystem", path, `${path} must contain a JSON array.`);
115
+ }
116
+ return raw;
117
+ }
118
+ export function saveContexts(adminRoot, contexts) {
119
+ writeJsonAtomic(contextFilePath(adminRoot), contexts);
120
+ }
121
+ const defaultClock = () => new Date().toISOString();
122
+ export function defaultGitRunner(cwd, args) {
123
+ const result = spawnSync("git", [...args], {
124
+ cwd,
125
+ encoding: "utf8",
126
+ });
127
+ if (result.error) {
128
+ throw new AdminError("git", cwd, result.error.message);
129
+ }
130
+ if (result.status !== 0) {
131
+ const detail = (result.stderr || result.stdout || "git command failed").trim();
132
+ throw new AdminError("git", cwd, detail);
133
+ }
134
+ return result.stdout ?? "";
135
+ }
136
+ export function addRepo(adminRoot, repoPath, options = {}) {
137
+ const path = assertDirectory(repoPath);
138
+ const repos = loadRepos(adminRoot);
139
+ const existing = repos.find((repo) => repo.path === path);
140
+ if (existing !== undefined) {
141
+ return { repo: existing, created: false };
142
+ }
143
+ const clock = options.clock ?? defaultClock;
144
+ const repo = { path, addedAt: clock() };
145
+ repos.push(repo);
146
+ saveRepos(adminRoot, repos);
147
+ return { repo, created: true };
148
+ }
149
+ export function listRepos(adminRoot) {
150
+ return loadRepos(adminRoot);
151
+ }
152
+ export function removeRepo(adminRoot, repoPath) {
153
+ const path = resolve(repoPath);
154
+ const repos = loadRepos(adminRoot);
155
+ const index = repos.findIndex((repo) => repo.path === path);
156
+ if (index < 0) {
157
+ throw new AdminError("not-registered", path, `${path} is not registered.`);
158
+ }
159
+ const [removed] = repos.splice(index, 1);
160
+ if (removed === undefined) {
161
+ throw new AdminError("not-registered", path, `${path} is not registered.`);
162
+ }
163
+ saveRepos(adminRoot, repos);
164
+ return removed;
165
+ }
166
+ function parseCommitLog(stdout) {
167
+ const commits = [];
168
+ const blocks = stdout.split(/\n(?=◆)/);
169
+ for (const block of blocks) {
170
+ const trimmed = block.trim();
171
+ if (trimmed.length === 0)
172
+ continue;
173
+ const lines = trimmed.split("\n");
174
+ const header = lines[0];
175
+ if (header === undefined || !header.startsWith("◆"))
176
+ continue;
177
+ const parts = header.slice(1).split("\0");
178
+ const subject = parts[0] ?? "";
179
+ const date = parts[1] ?? "";
180
+ const files = [];
181
+ for (const line of lines.slice(1)) {
182
+ const file = line.trim();
183
+ if (file.length === 0)
184
+ continue;
185
+ if (isSecretFilename(file))
186
+ continue;
187
+ files.push(file);
188
+ }
189
+ commits.push({ subject, date, files });
190
+ }
191
+ return commits;
192
+ }
193
+ export function readRepoContext(repoPath, options = {}) {
194
+ const path = assertDirectory(repoPath);
195
+ const git = options.git ?? defaultGitRunner;
196
+ const branch = git(path, ["rev-parse", "--abbrev-ref", "HEAD"]).trim();
197
+ const log = git(path, [
198
+ "log",
199
+ "-n",
200
+ "50",
201
+ "--pretty=format:◆%s%x00%cI",
202
+ "--name-only",
203
+ ]);
204
+ return {
205
+ path,
206
+ branch,
207
+ recentCommits: parseCommitLog(log),
208
+ };
209
+ }
210
+ export function refreshRepos(adminRoot, options = {}) {
211
+ const repos = loadRepos(adminRoot);
212
+ const contexts = [];
213
+ for (const repo of repos) {
214
+ contexts.push(readRepoContext(repo.path, options));
215
+ }
216
+ saveContexts(adminRoot, contexts);
217
+ return contexts;
218
+ }
219
+ export function formatRepos(repos) {
220
+ if (repos.length === 0)
221
+ return "No registered repositories.\n";
222
+ return `${repos.map((repo) => `${repo.path}\t${repo.addedAt}`).join("\n")}\n`;
223
+ }
224
+ export function formatContexts(contexts) {
225
+ if (contexts.length === 0)
226
+ return "No repository context.\n";
227
+ const lines = [];
228
+ for (const context of contexts) {
229
+ lines.push(`path: ${context.path}`);
230
+ lines.push(`branch: ${context.branch}`);
231
+ lines.push(`commits: ${context.recentCommits.length}`);
232
+ for (const commit of context.recentCommits) {
233
+ lines.push(`- ${commit.date}\t${commit.subject}`);
234
+ for (const file of commit.files) {
235
+ lines.push(` ${file}`);
236
+ }
237
+ }
238
+ lines.push("");
239
+ }
240
+ return `${lines.join("\n").trimEnd()}\n`;
241
+ }