@ajaykumarnpm/talea 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,67 @@
1
+ import { existsSync } from 'node:fs';
2
+
3
+ import { repoDir } from '../config.js';
4
+ import { c, fail, plain } from '../log.js';
5
+ import { requireWorkspace } from '../workspace.js';
6
+
7
+ export const help = `
8
+ ${c.bold('talea where')} — print a repo's path
9
+
10
+ ${c.dim('talea where eklavya')} the absolute path, one line, nothing else
11
+ ${c.dim('cd $(talea where eklavya)')} what it is actually for
12
+ ${c.dim('talea where')} the workspace root
13
+
14
+ The whole point of a fixed structure is never having to remember it. This is
15
+ the command that keeps that promise — the path goes to stdout on its own so it
16
+ composes with ${c.dim('cd')}, ${c.dim('code')}, ${c.dim('open')} and anything else that takes a directory.
17
+
18
+ Exits non-zero if the repo is not in the catalogue, so ${c.dim('cd $(talea where typo)')}
19
+ fails loudly instead of landing you in your home directory.
20
+ `;
21
+
22
+ export function run(opts, positionals = []) {
23
+ const { root, manifest } = requireWorkspace();
24
+ const name = positionals[0];
25
+
26
+ if (!name) {
27
+ // stdout, not through the logger: this is data, and it is going to be
28
+ // consumed by $( ).
29
+ process.stdout.write(root + '\n');
30
+ return;
31
+ }
32
+
33
+ const wanted = name.toLowerCase();
34
+ const matches = manifest.repos.filter(
35
+ (r) => r.name.toLowerCase() === wanted || `${r.owner}/${r.name}`.toLowerCase() === wanted,
36
+ );
37
+
38
+ if (!matches.length) {
39
+ // Everything diagnostic goes to stderr, so a failed lookup never puts a
40
+ // stray word on stdout where a shell would try to cd into it.
41
+ fail(`No repo called "${name}" in the catalogue.`);
42
+ const near = manifest.repos
43
+ .filter((r) => r.name.toLowerCase().includes(wanted))
44
+ .slice(0, 5);
45
+ if (near.length) {
46
+ console.error(`\n Did you mean: ${near.map((r) => c.bold(r.name)).join(', ')}`);
47
+ }
48
+ process.exit(1);
49
+ }
50
+
51
+ // Two owners can have a repo of the same name, and picking one silently would
52
+ // be the wrong kind of convenient.
53
+ if (matches.length > 1) {
54
+ fail(`"${name}" is ambiguous — ${matches.length} repos have that name.`);
55
+ for (const r of matches) console.error(` ${c.bold(`${r.owner}/${r.name}`)}`);
56
+ console.error(`\n Name the owner too: ${c.dim(`talea where ${matches[0].owner}/${matches[0].name}`)}`);
57
+ process.exit(1);
58
+ }
59
+
60
+ const dir = repoDir(manifest, root, matches[0]);
61
+ process.stdout.write(dir + '\n');
62
+
63
+ if (!existsSync(dir)) {
64
+ console.error(c.dim(`\n Not cloned yet — \`talea sync -r ${matches[0].name}\` will fetch it.`));
65
+ process.exit(1);
66
+ }
67
+ }
package/src/config.js ADDED
@@ -0,0 +1,155 @@
1
+ // Configuration comes from three places, nearest wins:
2
+ //
3
+ // <workspace>/talea.repos.json a catalogue that belongs to this tree only
4
+ // ~/.talea/talea.repos.json your catalogue — what `talea discover` writes
5
+ // manifest/talea.repos.json the packaged one, which ships EMPTY
6
+ //
7
+ // The packaged manifest is empty on purpose. talea is not a catalogue of
8
+ // anybody's repositories; it is the machinery for keeping your own in one
9
+ // shape on every machine. Shipping a repo list in the package would make the
10
+ // tool personal to whoever published it.
11
+ //
12
+ // <workspace>/.talea.json per-machine state — which repos this machine
13
+ // wants, which protocol, where it has looked
14
+ // for strays, what it has already moved.
15
+ //
16
+ // State is never shared. The catalogue is the thing that travels (see
17
+ // `talea manifest push`); the selection is the thing that does not.
18
+
19
+ import { readFileSync, writeFileSync, mkdirSync, existsSync } from 'node:fs';
20
+ import path from 'node:path';
21
+ import { fileURLToPath } from 'node:url';
22
+ import os from 'node:os';
23
+
24
+ const here = path.dirname(fileURLToPath(import.meta.url));
25
+
26
+ export const MANIFEST_NAME = 'talea.repos.json';
27
+ export const STATE_FILE = '.talea.json';
28
+
29
+ export const PACKAGED_MANIFEST = path.join(here, '..', 'manifest', MANIFEST_NAME);
30
+ export const USER_DIR = path.join(os.homedir(), '.talea');
31
+ export const USER_MANIFEST = path.join(USER_DIR, MANIFEST_NAME);
32
+ export const USER_STATE = path.join(USER_DIR, 'state.json');
33
+
34
+ const readJson = (file) => JSON.parse(readFileSync(file, 'utf8'));
35
+
36
+ /** Machine-wide state: the update-check stamp, the gist id. Not per workspace. */
37
+ export function readUserState() {
38
+ try {
39
+ return readJson(USER_STATE);
40
+ } catch {
41
+ return {};
42
+ }
43
+ }
44
+
45
+ export function writeUserState(state) {
46
+ try {
47
+ mkdirSync(USER_DIR, { recursive: true });
48
+ writeFileSync(USER_STATE, JSON.stringify(state, null, 2) + '\n');
49
+ } catch {
50
+ // A read-only home directory must not break the actual command. The only
51
+ // things kept here are a cache stamp and a gist id.
52
+ }
53
+ }
54
+
55
+ /** The catalogue files in precedence order, nearest first. */
56
+ export function manifestCandidates(workspaceRoot) {
57
+ return [
58
+ workspaceRoot ? path.join(workspaceRoot, MANIFEST_NAME) : null,
59
+ USER_MANIFEST,
60
+ PACKAGED_MANIFEST,
61
+ ].filter(Boolean);
62
+ }
63
+
64
+ export function loadManifest(workspaceRoot) {
65
+ const file = manifestCandidates(workspaceRoot).find((f) => existsSync(f));
66
+ const manifest = file ? readJson(file) : readJson(PACKAGED_MANIFEST);
67
+ manifest.__source = file ?? PACKAGED_MANIFEST;
68
+ manifest.repos ??= [];
69
+ manifest.groups ??= {};
70
+ return manifest;
71
+ }
72
+
73
+ /** Write the catalogue back to wherever it was loaded from, or to the user one. */
74
+ export function saveManifest(manifest, file) {
75
+ const dest =
76
+ file ?? (manifest.__source === PACKAGED_MANIFEST ? USER_MANIFEST : manifest.__source) ?? USER_MANIFEST;
77
+ const { __source, ...body } = manifest;
78
+ mkdirSync(path.dirname(dest), { recursive: true });
79
+ writeFileSync(dest, JSON.stringify(body, null, 2) + '\n');
80
+ return dest;
81
+ }
82
+
83
+ /**
84
+ * Find the workspace root by walking up from `start` looking for .talea.json.
85
+ * Mirrors how git finds .git, so commands work from anywhere inside the tree —
86
+ * including from inside one of the repos it manages.
87
+ */
88
+ export function findWorkspace(start = process.cwd()) {
89
+ let dir = path.resolve(start);
90
+ while (true) {
91
+ if (existsSync(path.join(dir, STATE_FILE))) return dir;
92
+ const parent = path.dirname(dir);
93
+ if (parent === dir) return null;
94
+ dir = parent;
95
+ }
96
+ }
97
+
98
+ export function loadState(workspaceRoot) {
99
+ const file = path.join(workspaceRoot, STATE_FILE);
100
+ return existsSync(file) ? readJson(file) : {};
101
+ }
102
+
103
+ export function saveState(workspaceRoot, state) {
104
+ const file = path.join(workspaceRoot, STATE_FILE);
105
+ writeFileSync(file, JSON.stringify(state, null, 2) + '\n');
106
+ }
107
+
108
+ /** Expand `~` so `talea init ~/Workspace` behaves the same on every shell. */
109
+ export function expandHome(p) {
110
+ if (p === '~') return os.homedir();
111
+ if (p.startsWith('~/') || p.startsWith('~\\')) {
112
+ return path.join(os.homedir(), p.slice(2));
113
+ }
114
+ return p;
115
+ }
116
+
117
+ /**
118
+ * The clone URL for a repo: an explicit override, else the host template with
119
+ * the repo's own owner filled in.
120
+ *
121
+ * `{owner}` is per repo rather than per manifest, which is the whole reason one
122
+ * catalogue can hold your personal repos and three orgs' repos at once.
123
+ */
124
+ export function repoUrl(manifest, repo, protocol = 'ssh') {
125
+ if (repo.url) return repo.url;
126
+ const template = manifest.remotes?.[protocol];
127
+ if (!template) {
128
+ throw new Error(
129
+ `Unknown remote protocol "${protocol}". Known: ${Object.keys(manifest.remotes ?? {}).join(', ')}`,
130
+ );
131
+ }
132
+ return template.replace('{owner}', repo.owner ?? '').replace('{repo}', repo.name);
133
+ }
134
+
135
+ /**
136
+ * The folder a group lives in. Equal to the catalogue key until a group is
137
+ * nested (`work/backend`), at which point printing the key names no real folder.
138
+ */
139
+ export const groupDir = (manifest, group) => manifest.groups?.[group]?.dir ?? group;
140
+
141
+ /** A repo's group: explicit, else its owner — so a fresh catalogue needs no curation. */
142
+ export const repoGroup = (repo) => repo.group ?? repo.owner ?? 'repos';
143
+
144
+ export const repoDir = (manifest, workspaceRoot, repo) =>
145
+ path.join(workspaceRoot, groupDir(manifest, repoGroup(repo)), repo.dir ?? repo.name);
146
+
147
+ /**
148
+ * The branch this repo is cloned on and fast-forwarded against.
149
+ *
150
+ * Recorded per repo by `talea discover`, because GitHub is the only thing that
151
+ * knows — assuming `main` is wrong for every repo cut before 2020 and for
152
+ * anyone whose default is `master`, `develop` or `trunk`. Never guessed: a repo
153
+ * with nothing recorded is cloned on whatever the server hands over.
154
+ */
155
+ export const defaultBranch = (repo) => repo.defaultBranch ?? null;
package/src/docs.js ADDED
@@ -0,0 +1,122 @@
1
+ // The CLAUDE.md files that describe a *folder of repos* rather than a repo.
2
+ //
3
+ // `ProjectAJ14/`, `nonstopio/`, `work/backend/` — the folders repos get cloned
4
+ // into — are not git repositories. So nothing can version the doc that says what
5
+ // lives in one, and every machine would have to be handed the file. The
6
+ // templates therefore ship with this package and `talea clone` drops them into
7
+ // place, keyed by group name: `templates/<GROUP>.CLAUDE.md`, plus
8
+ // `templates/root.CLAUDE.md` for the workspace root.
9
+ //
10
+ // talea ships NO templates. The mechanism is here so that adding a doc is
11
+ // adding a file; until one exists this writes nothing.
12
+ //
13
+ // Repo-level CLAUDE.md files are not our business: a committed one arrives with
14
+ // the clone, and a gitignored one belongs to whoever wrote it.
15
+
16
+ import { copyFileSync, existsSync, mkdirSync } from 'node:fs';
17
+ import path from 'node:path';
18
+ import { fileURLToPath } from 'node:url';
19
+
20
+ import { groupDir } from './config.js';
21
+
22
+ const here = path.dirname(fileURLToPath(import.meta.url));
23
+ export const TEMPLATES = path.join(here, '..', 'templates');
24
+
25
+ /** The template for a group, or for the workspace root when group is null. */
26
+ export const templateFor = (group) =>
27
+ path.join(TEMPLATES, `${group ?? 'root'}.CLAUDE.md`);
28
+
29
+ /**
30
+ * Every folder a group's docs belong in, innermost last: `work/backend` is the
31
+ * group, but `work/` above it is a real folder that no group owns and nothing
32
+ * else would ever document. Keyed by folder name, so `work.CLAUDE.md` is the
33
+ * only wiring a parent folder needs.
34
+ *
35
+ * A group's `dir` is catalogue data, and a typo in it used to be silent in two
36
+ * different ways — both of which cost the workspace-root doc, which is outside
37
+ * git and therefore unrecoverable:
38
+ *
39
+ * dir: "./work/api" `path.join(root, ".")` is `root`, so the group's own
40
+ * key displaced the root's entry in `targets`;
41
+ * `templateFor(".")` names no file, the write was
42
+ * skipped, and the root CLAUDE.md was never written.
43
+ * dir: "" or "." same collapse, except the key that lands on `root` is
44
+ * the GROUP's — so the workspace root was handed the
45
+ * group's doc. Never overwritten afterwards, so the
46
+ * wrong file at the root became permanent.
47
+ *
48
+ * Both are a malformed catalogue, so they are an error now. "Fail loudly on
49
+ * typos" is rule 5 for exactly this: a bulk command that quietly does the wrong
50
+ * thing is worse than one that stops.
51
+ */
52
+ function docFolders(dir, group) {
53
+ // Split on '/' only: catalogue dirs are authored with forward slashes on
54
+ // every platform. Quietly accepting a backslash here would be papering over a
55
+ // different typo, and this function's job is to refuse them.
56
+ const raw = String(dir ?? '');
57
+ const segs = raw.split('/');
58
+ const usable =
59
+ segs.every((seg) => seg !== '' && seg !== '.' && seg !== '..') && !path.isAbsolute(raw);
60
+
61
+ if (!usable) {
62
+ throw new Error(
63
+ `Group "${group}" has an unusable dir ${JSON.stringify(dir)}.\n` +
64
+ ' A group dir must be a relative path below the workspace root, like "work/api".\n' +
65
+ ' Fix it in the catalogue (manifest/talea.repos.json).',
66
+ );
67
+ }
68
+
69
+ return segs.map((seg, i) => [
70
+ i === segs.length - 1 ? group : seg,
71
+ segs.slice(0, i + 1),
72
+ ]);
73
+ }
74
+
75
+ /**
76
+ * Write the workspace-root, parent-folder and per-group CLAUDE.md files that
77
+ * have a template.
78
+ *
79
+ * **Never overwrites.** These files are outside git by design, so an overwrite
80
+ * cannot be recovered — a developer's own edits to their own untracked doc
81
+ * outrank the packaged copy every time. A group with no template is skipped,
82
+ * which is how a group opts out — though for a nested group that means deleting
83
+ * the parent's template too (`PORTAL.CLAUDE.md` is reached through V1's and
84
+ * V2's `dir`, not through either group's own name).
85
+ *
86
+ * Throws on a group whose `dir` cannot name a folder below the root. See
87
+ * `docFolders`.
88
+ */
89
+ export function dropDocs(manifest, root, groups) {
90
+ // Keyed by folder so a parent shared by two groups (`PORTAL/` under both V1
91
+ // and V2) is considered once.
92
+ const targets = new Map([[root, null]]);
93
+ for (const g of [...groups].sort()) {
94
+ for (const [key, segs] of docFolders(groupDir(manifest, g), g)) {
95
+ // `docFolders` guarantees at least one segment that is not '', '.' or
96
+ // '..', so this can never join back to `root` and displace its entry —
97
+ // which is how the root doc used to go missing. The guarantee lives
98
+ // there, with a test, rather than as an unreachable guard here.
99
+ targets.set(path.join(root, ...segs), key);
100
+ }
101
+ }
102
+
103
+ const written = [];
104
+ const kept = [];
105
+
106
+ for (const [dir, group] of targets) {
107
+ const src = templateFor(group);
108
+ if (!existsSync(src)) continue;
109
+
110
+ const dst = path.join(dir, 'CLAUDE.md');
111
+ if (existsSync(dst)) {
112
+ kept.push(dst);
113
+ continue;
114
+ }
115
+
116
+ mkdirSync(dir, { recursive: true });
117
+ copyFileSync(src, dst);
118
+ written.push(dst);
119
+ }
120
+
121
+ return { written, kept };
122
+ }
package/src/git.js ADDED
@@ -0,0 +1,291 @@
1
+ // Git plumbing. Everything shells out to the `git` binary with shell:false so
2
+ // that repo names, branch names and Windows paths containing spaces are passed
3
+ // through verbatim — no quoting rules to get wrong on cmd.exe vs bash.
4
+
5
+ import { spawn } from 'node:child_process';
6
+ import { existsSync, statSync, unlinkSync } from 'node:fs';
7
+ import { availableParallelism } from 'node:os';
8
+ import path from 'node:path';
9
+
10
+ // Environment variables that point git at a specific repository, overriding
11
+ // `cwd` completely. Git exports several of them into hooks, so a `talea`
12
+ // command run from inside one — or from any wrapper that exports them — would
13
+ // otherwise run every operation against the wrong repository. `adopt` decides
14
+ // what to move from `git remote get-url origin`, so a leaked GIT_DIR makes 47
15
+ // separate checkouts all report one remote and look like copies of each other.
16
+ //
17
+ // Cleared rather than trusted: `cwd` is the only thing that should select the
18
+ // repository here.
19
+ const REPO_SCOPING_VARS = [
20
+ 'GIT_DIR',
21
+ 'GIT_WORK_TREE',
22
+ 'GIT_INDEX_FILE',
23
+ 'GIT_OBJECT_DIRECTORY',
24
+ 'GIT_ALTERNATE_OBJECT_DIRECTORIES',
25
+ 'GIT_COMMON_DIR',
26
+ 'GIT_NAMESPACE',
27
+ 'GIT_PREFIX',
28
+ 'GIT_CEILING_DIRECTORIES',
29
+ ];
30
+
31
+ /** process.env with anything that would redirect git away from `cwd` removed. */
32
+ export function cleanGitEnv(base = process.env) {
33
+ const env = { ...base };
34
+ // Node omits a variable whose value is undefined when spawning.
35
+ for (const name of REPO_SCOPING_VARS) env[name] = undefined;
36
+ return env;
37
+ }
38
+
39
+ /**
40
+ * Run git and capture output. Never throws on a non-zero exit; callers decide
41
+ * what a failure means, which keeps "branch missing" from looking like a crash.
42
+ */
43
+ export function git(args, { cwd, env, input } = {}) {
44
+ return new Promise((resolve) => {
45
+ const child = spawn('git', args, {
46
+ cwd,
47
+ shell: false,
48
+ env: {
49
+ ...cleanGitEnv(),
50
+ // Never let git stop mid-run waiting for credentials — a hung prompt
51
+ // across 47 repos is far worse than a clean per-repo failure.
52
+ GIT_TERMINAL_PROMPT: '0',
53
+ GIT_ASKPASS: '',
54
+ ...env,
55
+ },
56
+ stdio: [input == null ? 'ignore' : 'pipe', 'pipe', 'pipe'],
57
+ });
58
+
59
+ if (input != null) {
60
+ // A git that exits before reading all of stdin (or is missing) gives an
61
+ // EPIPE here; the exit code and stderr are the real signal, so swallow it.
62
+ child.stdin.on('error', () => {});
63
+ child.stdin.end(input);
64
+ }
65
+
66
+ let stdout = '';
67
+ let stderr = '';
68
+ child.stdout.on('data', (d) => (stdout += d));
69
+ child.stderr.on('data', (d) => (stderr += d));
70
+ child.on('error', (err) =>
71
+ resolve({ code: -1, stdout: '', stderr: err.message }),
72
+ );
73
+ child.on('close', (code) =>
74
+ resolve({ code, stdout: stdout.trim(), stderr: stderr.trim() }),
75
+ );
76
+ });
77
+ }
78
+
79
+ // Git writes `<ref>.lock` while it updates a ref and removes it when it is done.
80
+ // A fetch that dies mid-write — Ctrl-C, a closed laptop, a killed pool worker —
81
+ // leaves the lock behind, and every fetch afterwards fails with
82
+ // "cannot lock ref … File exists" until a human deletes the file. Across 47
83
+ // repos that is a permanent, self-inflicted failure, so clear the locks git
84
+ // itself named in the error and run the command again.
85
+ //
86
+ // Age is the only safe test for "nobody owns this": a lock a live git holds is
87
+ // at most seconds old, whether it is ours or another terminal's. Anything older
88
+ // than a minute is debris. This never touches anything but a `.lock` file —
89
+ // git forbids a ref name ending in `.lock`, so there is no repo content behind
90
+ // that suffix.
91
+ const STALE_LOCK_MS = 60_000;
92
+
93
+ /** Absolute paths of the `*.lock` files quoted in a git error message. */
94
+ function lockPathsIn(stderr, cwd) {
95
+ const quoted = String(stderr).matchAll(/'([^']*\.lock)'/g);
96
+ return [...new Set([...quoted].map((m) => path.resolve(cwd ?? process.cwd(), m[1])))];
97
+ }
98
+
99
+ /** Remove the locks git named that are too old to belong to a running git. */
100
+ function clearStaleLocks(stderr, cwd) {
101
+ let cleared = 0;
102
+ for (const lock of lockPathsIn(stderr, cwd)) {
103
+ try {
104
+ if (Date.now() - statSync(lock).mtimeMs < STALE_LOCK_MS) continue;
105
+ unlinkSync(lock);
106
+ cleared++;
107
+ } catch {
108
+ // Already gone, or not ours to remove. The retry decides either way.
109
+ }
110
+ }
111
+ return cleared;
112
+ }
113
+
114
+ // The other way "cannot lock ref … File exists" happens, and no lock file on
115
+ // disk explains it: macOS and Windows filesystems are case-insensitive, so two
116
+ // branches on origin whose names differ only in case —
117
+ // `INT-1203-on-UAT` and `INT-1203-on-Uat`, `optimize-CICD` and `optimize-cicd`
118
+ // — are one path locally. `fetch --prune` locks the first, collides on the
119
+ // second, and fails. Nothing is stale and waiting does not help: the pair can
120
+ // never both exist as files, so every fetch fails until one of them is gone.
121
+ //
122
+ // Dropping the ref git named is the recovery, and this is the only place it is
123
+ // safe: a `refs/remotes/` ref is a cache of origin that the same fetch rebuilds.
124
+ // Nothing under `refs/heads/` or `refs/stash` is ever touched here.
125
+ const CANNOT_LOCK = /cannot lock ref '(refs\/remotes\/[^']+)'/g;
126
+
127
+ async function dropCollidingRefs(stderr, opts) {
128
+ // A lock file that is still on disk means a live git owns it — the age check
129
+ // above already declined to remove it, so do not go behind its back either.
130
+ if (lockPathsIn(stderr, opts?.cwd).some(existsSync)) return 0;
131
+
132
+ const named = new Set([...String(stderr).matchAll(CANNOT_LOCK)].map((m) => m[1]));
133
+ let dropped = 0;
134
+ for (const ref of named) {
135
+ const { code: exists } = await git(['show-ref', '--verify', '--quiet', ref], opts);
136
+ if (exists !== 0) continue; // already gone; not progress, so do not loop on it
137
+ const { code } = await git(['update-ref', '-d', ref], opts);
138
+ if (code === 0) dropped++;
139
+ }
140
+ return dropped;
141
+ }
142
+
143
+ /**
144
+ * Run git, healing the two ref-lock failures that a retry alone cannot fix:
145
+ * debris from a killed git, and a case-collision between two remote branches.
146
+ * Retries only while it is actually clearing something, so a genuine failure
147
+ * returns after the first run and a lock a live git holds is reported rather
148
+ * than stolen.
149
+ */
150
+ async function unlocking(args, opts) {
151
+ let res = await git(args, opts);
152
+ // Git reports one collision per run, and a repo with years of branches can
153
+ // have several, so the cap is per-repo patience rather than per-failure.
154
+ for (let i = 0; i < 10 && res.code !== 0; i++) {
155
+ const healed =
156
+ clearStaleLocks(res.stderr, opts?.cwd) || (await dropCollidingRefs(res.stderr, opts));
157
+ if (!healed) break;
158
+ res = await git(args, opts);
159
+ }
160
+ return res;
161
+ }
162
+
163
+ /**
164
+ * Origin is gone, renamed, or this account was never granted it. Neither a
165
+ * retry nor anything the developer can do at their keyboard fixes it, so bulk
166
+ * commands report it and move on instead of failing the whole run.
167
+ */
168
+ export const isMissingRemote = (stderr) =>
169
+ /TF401019|repository not found|does not exist or you do not have permission/i.test(
170
+ String(stderr),
171
+ );
172
+
173
+ export const isRepo = (dir) => existsSync(path.join(dir, '.git'));
174
+
175
+ export async function gitVersion() {
176
+ const { code, stdout } = await git(['--version']);
177
+ return code === 0 ? stdout.replace('git version ', '') : null;
178
+ }
179
+
180
+ export async function currentBranch(dir) {
181
+ const { code, stdout } = await git(['rev-parse', '--abbrev-ref', 'HEAD'], {
182
+ cwd: dir,
183
+ });
184
+ return code === 0 ? stdout : null;
185
+ }
186
+
187
+ /** True when the working tree has uncommitted changes (tracked or untracked). */
188
+ export async function isDirty(dir) {
189
+ const { code, stdout } = await git(['status', '--porcelain'], { cwd: dir });
190
+ return code === 0 && stdout.length > 0;
191
+ }
192
+
193
+ /** Commits ahead of / behind the upstream, or null when there is no upstream. */
194
+ export async function aheadBehind(dir) {
195
+ const { code, stdout } = await git(
196
+ ['rev-list', '--left-right', '--count', '@{upstream}...HEAD'],
197
+ { cwd: dir },
198
+ );
199
+ if (code !== 0) return null;
200
+ const [behind, ahead] = stdout.split(/\s+/).map(Number);
201
+ return { ahead, behind };
202
+ }
203
+
204
+ /** Does `branch` exist on the remote? Uses the local remote-tracking refs. */
205
+ export async function remoteHasBranch(dir, branch) {
206
+ const { code } = await git(
207
+ ['show-ref', '--verify', '--quiet', `refs/remotes/origin/${branch}`],
208
+ { cwd: dir },
209
+ );
210
+ return code === 0;
211
+ }
212
+
213
+ export async function localHasBranch(dir, branch) {
214
+ const { code } = await git(
215
+ ['show-ref', '--verify', '--quiet', `refs/heads/${branch}`],
216
+ { cwd: dir },
217
+ );
218
+ return code === 0;
219
+ }
220
+
221
+ export const fetch = (dir) => unlocking(['fetch', '--prune', 'origin'], { cwd: dir });
222
+
223
+ /**
224
+ * Check out `branch`, creating it from origin/<branch> on first use.
225
+ * Assumes the caller has already fetched and confirmed the branch exists.
226
+ */
227
+ export async function checkout(dir, branch) {
228
+ if (await localHasBranch(dir, branch)) {
229
+ return unlocking(['checkout', branch], { cwd: dir });
230
+ }
231
+ return unlocking(['checkout', '-b', branch, '--track', `origin/${branch}`], {
232
+ cwd: dir,
233
+ });
234
+ }
235
+
236
+ /**
237
+ * Fast-forward the current branch onto its already-fetched upstream.
238
+ *
239
+ * Fast-forward only: a merge commit invented across 47 repos is the kind of
240
+ * history nobody can unpick afterwards, so a diverged branch fails loudly.
241
+ *
242
+ * This is deliberately `merge`, not `pull`. Every caller fetches first, and
243
+ * `git pull` fetches *again* — one extra network round-trip per repo, which
244
+ * over 47 repos on a VPN was most of the wall-clock time `talea sync` spent.
245
+ * Merging the ref that fetch just updated does the same work with one.
246
+ */
247
+ export const ffMerge = (dir) =>
248
+ unlocking(['merge', '--ff-only', '@{upstream}'], { cwd: dir });
249
+
250
+ /** Does the current branch track anything? `ffMerge` has nothing to do if not. */
251
+ export async function hasUpstream(dir) {
252
+ const { code } = await git(['rev-parse', '--abbrev-ref', '--symbolic-full-name', '@{upstream}'], {
253
+ cwd: dir,
254
+ });
255
+ return code === 0;
256
+ }
257
+
258
+ export const stashPush = (dir, message) =>
259
+ git(['stash', 'push', '--include-untracked', '-m', message], { cwd: dir });
260
+
261
+ export const clone = (url, dest, branch) =>
262
+ git(['clone', ...(branch ? ['--branch', branch] : []), '--', url, dest]);
263
+
264
+ /**
265
+ * How many git operations run at once when `-j` is not given.
266
+ *
267
+ * This work is network-bound, not CPU-bound, so the core count is a proxy for
268
+ * "how big is this machine" rather than a real limit — but it beats the flat 6
269
+ * this used to be, which left a modern laptop idle for most of a clone. Capped
270
+ * at 12 because the far end is one SSH server and 47 simultaneous sessions is
271
+ * how you get throttled; floored at 6 so it never runs slower than it used to.
272
+ */
273
+ export const defaultJobs = () => Math.min(12, Math.max(6, availableParallelism()));
274
+
275
+ /**
276
+ * Run `tasks` with a bounded number in flight. Cloning 47 repos serially is
277
+ * slow; cloning them all at once saturates the network and the SSH server.
278
+ */
279
+ export async function pooled(items, limit, worker) {
280
+ const results = new Array(items.length);
281
+ let next = 0;
282
+ const runners = Array.from({ length: Math.min(limit, items.length) }, async () => {
283
+ while (true) {
284
+ const i = next++;
285
+ if (i >= items.length) return;
286
+ results[i] = await worker(items[i], i);
287
+ }
288
+ });
289
+ await Promise.all(runners);
290
+ return results;
291
+ }