wize-dev-kit 0.26.1 → 0.27.2
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/ARCH.md +2 -1
- package/CHANGELOG.md +64 -0
- package/README.es.md +28 -6
- package/README.md +28 -6
- package/README.pt-BR.md +28 -6
- package/adapters/continue/render.js +2 -3
- package/adapters/copilot/render.js +3 -3
- package/adapters/cursor/render.js +2 -3
- package/adapters/generic/render.js +2 -3
- package/adapters/opencode/render.js +3 -4
- package/adapters/windsurf/render.js +2 -3
- package/docs/WORKTREES.md +150 -0
- package/docs/WORKTREES.pt-BR.md +150 -0
- package/docs/harnesses/antigravity.md +2 -0
- package/docs/harnesses/antigravity.pt-BR.md +2 -0
- package/docs/harnesses/claude-code.md +2 -0
- package/docs/harnesses/claude-code.pt-BR.md +2 -0
- package/docs/harnesses/codex.md +3 -1
- package/docs/harnesses/codex.pt-BR.md +3 -1
- package/docs/harnesses/continue.md +2 -0
- package/docs/harnesses/continue.pt-BR.md +2 -0
- package/docs/harnesses/copilot.md +2 -0
- package/docs/harnesses/copilot.pt-BR.md +2 -0
- package/docs/harnesses/cursor.md +2 -0
- package/docs/harnesses/cursor.pt-BR.md +2 -0
- package/docs/harnesses/generic.md +2 -0
- package/docs/harnesses/generic.pt-BR.md +2 -0
- package/docs/harnesses/hermes.md +2 -0
- package/docs/harnesses/hermes.pt-BR.md +2 -0
- package/docs/harnesses/kimi-code.md +2 -0
- package/docs/harnesses/kimi-code.pt-BR.md +2 -0
- package/docs/harnesses/kiro.md +2 -0
- package/docs/harnesses/kiro.pt-BR.md +2 -0
- package/docs/harnesses/opencode.md +2 -0
- package/docs/harnesses/opencode.pt-BR.md +2 -0
- package/docs/harnesses/windsurf.md +2 -0
- package/docs/harnesses/windsurf.pt-BR.md +2 -0
- package/package.json +1 -1
- package/src/orchestrator-skills/module.yaml +1 -1
- package/src/orchestrator-skills/wize/skill.md +17 -7
- package/src/orchestrator-skills/wize-help/skill.md +20 -7
- package/src/orchestrator-skills/wize-update/skill.md +33 -27
- package/tools/installer/commands/doctor.js +186 -40
- package/tools/installer/commands/document-project.js +13 -3
- package/tools/installer/commands/hooks.js +215 -0
- package/tools/installer/commands/hydrate.js +298 -0
- package/tools/installer/commands/sync.js +50 -23
- package/tools/installer/commands/update.js +596 -120
- package/tools/installer/commands/version-check.js +69 -10
- package/tools/installer/document-project/modes/full-rescan.js +1 -1
- package/tools/installer/document-project/modes/initial-scan.js +5 -4
- package/tools/installer/document-project/modes/quick.js +11 -4
- package/tools/installer/git-context.js +219 -0
- package/tools/installer/git-ops.js +211 -0
- package/tools/installer/install-plan.js +136 -0
- package/tools/installer/marker.js +67 -0
- package/tools/installer/project-root.js +68 -0
- package/tools/installer/project-status.js +171 -0
- package/tools/installer/render-shared.js +170 -4
- package/tools/installer/render-targets.js +112 -0
- package/tools/installer/semver.js +59 -0
- package/tools/installer/setup-helpers.js +89 -31
- package/tools/installer/templates/hydrate.sh +163 -0
- package/tools/installer/templates/wize-kit-update.yml +38 -0
- package/tools/installer/toml.js +133 -0
- package/tools/installer/util.js +16 -0
- package/tools/installer/version-check.js +167 -37
- package/tools/installer/wize-cli.js +216 -78
- package/tools/installer/sync.js +0 -29
|
@@ -6,33 +6,92 @@
|
|
|
6
6
|
// by a human, or by an AI agent shelling out from a skill instruction where
|
|
7
7
|
// stdout is never a TTY — so it always answers, in `--json` when a caller
|
|
8
8
|
// needs to parse it.
|
|
9
|
+
//
|
|
10
|
+
// The answer carries the project's state and one `action` with the exact
|
|
11
|
+
// command to run, so a caller never compares versions itself.
|
|
9
12
|
|
|
10
13
|
'use strict';
|
|
11
14
|
|
|
12
|
-
const
|
|
15
|
+
const path = require('node:path');
|
|
16
|
+
const { getVersionCheckDetailed } = require('../version-check.js');
|
|
17
|
+
const { getProjectStatus, ACTION_EXIT_CODES } = require('../project-status.js');
|
|
18
|
+
|
|
19
|
+
const DEFAULT_KIT_ROOT = path.resolve(__dirname, '..', '..', '..');
|
|
20
|
+
|
|
21
|
+
const ACTION_TEXT = {
|
|
22
|
+
hydrate: (s) => `This checkout is not hydrated (${s.reason}): run \`${s.commands.hydrate}\``,
|
|
23
|
+
'use-pin': (s) => `Project pinned ${s.pin} is newer than this kit (${s.installed}): run \`${s.commands.hydrate}\``,
|
|
24
|
+
install: (s) => `No .wize/config/project.toml here: run \`${s.commands.install}\``,
|
|
25
|
+
update: (s) => `Project pinned ${s.pin || 'none'} (${s.reason}): run \`${s.commands.update}\``
|
|
26
|
+
};
|
|
27
|
+
|
|
28
|
+
const KIT_NOT_ON_BRANCH_TEXT = (s) => `The kit is installed on ${s.kit_at}'s branch but not on this one (kit-not-on-branch): merge the kit into this branch, or pass --here to install here (\`${s.commands.install} --here\`)`;
|
|
13
29
|
|
|
14
|
-
async function cmdVersionCheck({
|
|
15
|
-
|
|
30
|
+
async function cmdVersionCheck({
|
|
31
|
+
currentVersion,
|
|
32
|
+
kitRoot = DEFAULT_KIT_ROOT,
|
|
33
|
+
projectRoot = null,
|
|
34
|
+
cwd = process.cwd(),
|
|
35
|
+
json = false,
|
|
36
|
+
refresh = false,
|
|
37
|
+
quiet = false,
|
|
38
|
+
exitCode = false,
|
|
39
|
+
log = console.log
|
|
40
|
+
} = {}) {
|
|
41
|
+
const { published_versions: published, ...base } = await getVersionCheckDetailed(currentVersion, { skipCache: refresh });
|
|
42
|
+
|
|
43
|
+
// The background refresher: fetch, write the cache, say nothing.
|
|
44
|
+
if (refresh && quiet) return { ...base, exitCode: 0 };
|
|
45
|
+
|
|
46
|
+
const status = getProjectStatus({ kitRoot, cwd: projectRoot || cwd, latest: base.latest, latestSource: base.latest_source, published });
|
|
47
|
+
const hasProject = !!status.root;
|
|
48
|
+
const result = {
|
|
49
|
+
...base,
|
|
50
|
+
schema: 2,
|
|
51
|
+
project: hasProject ? {
|
|
52
|
+
root: status.root,
|
|
53
|
+
pin: status.pin,
|
|
54
|
+
pin_status: status.pin_status,
|
|
55
|
+
hydrated: status.hydrated,
|
|
56
|
+
missing_targets: status.missing_targets,
|
|
57
|
+
marker_version: status.marker_version,
|
|
58
|
+
user_toml: status.user_toml
|
|
59
|
+
} : null,
|
|
60
|
+
git: status.git,
|
|
61
|
+
...(status.kit_at ? { kit_at: status.kit_at } : {}),
|
|
62
|
+
skew: status.skew,
|
|
63
|
+
action: status.action,
|
|
64
|
+
reason: status.reason,
|
|
65
|
+
commands: status.commands
|
|
66
|
+
};
|
|
67
|
+
result.exitCode = exitCode ? (ACTION_EXIT_CODES[status.action] ?? 0) : 0;
|
|
16
68
|
|
|
17
69
|
if (json) {
|
|
18
|
-
|
|
70
|
+
const { exitCode: _omit, ...printable } = result;
|
|
71
|
+
log(JSON.stringify(printable));
|
|
19
72
|
return result;
|
|
20
73
|
}
|
|
21
74
|
|
|
22
75
|
if (result.disabled) {
|
|
23
76
|
log('Update check disabled (WIZE_DISABLE_UPDATE_CHECK=1).');
|
|
24
|
-
|
|
25
|
-
}
|
|
26
|
-
if (!result.latest) {
|
|
77
|
+
} else if (!result.latest) {
|
|
27
78
|
log(`wize-dev-kit ${result.installed} (could not reach the registry — offline or timed out).`);
|
|
28
|
-
|
|
29
|
-
}
|
|
30
|
-
if (result.updateAvailable) {
|
|
79
|
+
} else if (result.updateAvailable) {
|
|
31
80
|
log(`↑ Update available: wize-dev-kit ${result.installed} → ${result.latest}`);
|
|
32
81
|
log(' Run `npx wize-dev-kit@latest update` in your project to refresh adapters — or `/wize-update` from inside your AI harness.');
|
|
33
82
|
} else {
|
|
34
83
|
log(`wize-dev-kit ${result.installed} is up to date.`);
|
|
35
84
|
}
|
|
85
|
+
|
|
86
|
+
if (status.reason === 'kit-not-on-branch') {
|
|
87
|
+
log(`→ ${KIT_NOT_ON_BRANCH_TEXT(status)}`);
|
|
88
|
+
return result;
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
const covered = status.action === 'update' && result.updateAvailable;
|
|
92
|
+
if (status.action !== 'none' && !covered && ACTION_TEXT[status.action]) {
|
|
93
|
+
log(`→ ${ACTION_TEXT[status.action](status)}`);
|
|
94
|
+
}
|
|
36
95
|
return result;
|
|
37
96
|
}
|
|
38
97
|
|
|
@@ -9,7 +9,7 @@ const { runInitialScan } = require('./initial-scan.js');
|
|
|
9
9
|
|
|
10
10
|
function runFullRescan(projectRoot, options = {}) {
|
|
11
11
|
archiveOldState(projectRoot);
|
|
12
|
-
return runInitialScan(projectRoot, { ...options, mode: 'full_rescan' });
|
|
12
|
+
return runInitialScan(projectRoot, { ...options, mode: 'full_rescan', overwrite: true });
|
|
13
13
|
}
|
|
14
14
|
|
|
15
15
|
module.exports = { runFullRescan };
|
|
@@ -39,7 +39,7 @@ function existingDocs(root) {
|
|
|
39
39
|
return candidates.filter(f => fs.existsSync(path.join(root, f))).map(f => path.join(root, f));
|
|
40
40
|
}
|
|
41
41
|
|
|
42
|
-
function runInitialScan(projectRoot, { scanLevel = 'quick', log = console.log, csvPath } = {}) {
|
|
42
|
+
function runInitialScan(projectRoot, { scanLevel = 'quick', log = console.log, csvPath, overwrite = false } = {}) {
|
|
43
43
|
const classification = classifyProject(projectRoot, csvPath ? { csvPath } : {});
|
|
44
44
|
const state = initState(projectRoot, 'initial_scan', scanLevel);
|
|
45
45
|
|
|
@@ -68,9 +68,10 @@ function runInitialScan(projectRoot, { scanLevel = 'quick', log = console.log, c
|
|
|
68
68
|
});
|
|
69
69
|
}
|
|
70
70
|
|
|
71
|
-
//
|
|
72
|
-
const quick = runQuick(projectRoot, { log });
|
|
73
|
-
|
|
71
|
+
// Write the missing baseline files (existing ones are kept unless overwrite) + index.
|
|
72
|
+
const quick = runQuick(projectRoot, { log, overwrite });
|
|
73
|
+
const baselineDir = path.join(projectRoot, '.wize', 'knowledge', 'document-project');
|
|
74
|
+
generated.push(...[...quick.written, ...(quick.skipped || [])].map(f => path.join(baselineDir, f)));
|
|
74
75
|
|
|
75
76
|
const indexResult = renderIndex(projectRoot, {
|
|
76
77
|
projectTypes: classification.projectTypes,
|
|
@@ -196,16 +196,23 @@ function runQuick(projectRoot, opts = {}) {
|
|
|
196
196
|
const dir = path.join(projectRoot, '.wize', 'knowledge', 'document-project');
|
|
197
197
|
fs.mkdirSync(dir, { recursive: true });
|
|
198
198
|
|
|
199
|
+
const overwrite = opts.overwrite === true;
|
|
199
200
|
const written = [];
|
|
201
|
+
const skipped = [];
|
|
200
202
|
for (const [name, factory] of Object.entries(BASELINE_FILES)) {
|
|
201
203
|
const file = path.join(dir, name);
|
|
202
|
-
|
|
203
|
-
|
|
204
|
+
if (!overwrite && fs.existsSync(file)) {
|
|
205
|
+
skipped.push(name);
|
|
206
|
+
continue;
|
|
207
|
+
}
|
|
208
|
+
fs.writeFileSync(file, factory(projectRoot, pkg), 'utf-8');
|
|
204
209
|
written.push(name);
|
|
205
210
|
}
|
|
206
211
|
|
|
207
|
-
if (opts.log)
|
|
208
|
-
|
|
212
|
+
if (opts.log) {
|
|
213
|
+
opts.log(`Quick baseline: wrote ${written.length}, kept ${skipped.length} existing (use --force to overwrite)`);
|
|
214
|
+
}
|
|
215
|
+
return { changed: written.length > 0, mode: 'quick', written, skipped };
|
|
209
216
|
}
|
|
210
217
|
|
|
211
218
|
module.exports = { runQuick, BASELINE_FILES, countLines };
|
|
@@ -0,0 +1,219 @@
|
|
|
1
|
+
// Read-only git facts about a project root: is it a repo, a linked worktree,
|
|
2
|
+
// which branch, where the primary checkout lives. Every call is bounded and
|
|
3
|
+
// failure-safe; a missing git binary degrades to a `.git` file/dir probe.
|
|
4
|
+
|
|
5
|
+
'use strict';
|
|
6
|
+
|
|
7
|
+
const fs = require('node:fs');
|
|
8
|
+
const path = require('node:path');
|
|
9
|
+
const { execFileSync } = require('node:child_process');
|
|
10
|
+
const { real } = require('./util.js');
|
|
11
|
+
|
|
12
|
+
function git(root, args, { timeout = 3000 } = {}) {
|
|
13
|
+
try {
|
|
14
|
+
return execFileSync('git', args, {
|
|
15
|
+
cwd: root,
|
|
16
|
+
env: { ...process.env, GIT_OPTIONAL_LOCKS: '0' },
|
|
17
|
+
timeout,
|
|
18
|
+
encoding: 'utf-8',
|
|
19
|
+
maxBuffer: 256 * 1024 * 1024,
|
|
20
|
+
stdio: ['ignore', 'pipe', 'ignore']
|
|
21
|
+
});
|
|
22
|
+
} catch {
|
|
23
|
+
return null;
|
|
24
|
+
}
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
function revParseTriple(root) {
|
|
28
|
+
let out = git(root, ['rev-parse', '--path-format=absolute', '--show-toplevel', '--git-dir', '--git-common-dir']);
|
|
29
|
+
if (out === null) out = git(root, ['rev-parse', '--show-toplevel', '--git-dir', '--git-common-dir']);
|
|
30
|
+
if (out === null) return null;
|
|
31
|
+
const lines = out.split('\n').filter(Boolean);
|
|
32
|
+
if (lines.length < 3) return null;
|
|
33
|
+
return lines.slice(0, 3).map(l => real(path.resolve(root, l)));
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
// Parses `git worktree list --porcelain` into [{ path, head, branch, detached, bare }].
|
|
37
|
+
function worktreeList(root) {
|
|
38
|
+
const out = git(root, ['worktree', 'list', '--porcelain']);
|
|
39
|
+
if (out === null) return null;
|
|
40
|
+
const list = [];
|
|
41
|
+
let cur = null;
|
|
42
|
+
for (const line of out.split('\n')) {
|
|
43
|
+
if (line.startsWith('worktree ')) {
|
|
44
|
+
cur = { path: line.slice('worktree '.length), head: null, branch: null, detached: false, bare: false };
|
|
45
|
+
list.push(cur);
|
|
46
|
+
} else if (!cur) {
|
|
47
|
+
continue;
|
|
48
|
+
} else if (line.startsWith('HEAD ')) cur.head = line.slice(5);
|
|
49
|
+
else if (line.startsWith('branch ')) cur.branch = line.slice(7).replace(/^refs\/heads\//, '');
|
|
50
|
+
else if (line === 'detached') cur.detached = true;
|
|
51
|
+
else if (line === 'bare') cur.bare = true;
|
|
52
|
+
}
|
|
53
|
+
return list;
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
function fallbackContext(root) {
|
|
57
|
+
const base = { isRepo: false, toplevel: null, gitDir: null, commonDir: null, linked: false, primary: null, branch: null, detached: false, head: null };
|
|
58
|
+
// git failed: a .git above the root (monorepo subdir) still means a repo,
|
|
59
|
+
// so the root's caller must keep its git guards on instead of going non-git.
|
|
60
|
+
let top = root;
|
|
61
|
+
let st;
|
|
62
|
+
for (;;) {
|
|
63
|
+
try { st = fs.statSync(path.join(top, '.git')); break; } catch { /* keep walking */ }
|
|
64
|
+
const up = path.dirname(top);
|
|
65
|
+
if (up === top) return base;
|
|
66
|
+
top = up;
|
|
67
|
+
}
|
|
68
|
+
const dotGit = path.join(top, '.git');
|
|
69
|
+
if (st.isDirectory()) {
|
|
70
|
+
return { ...base, isRepo: true, toplevel: real(top), gitDir: real(dotGit), commonDir: real(dotGit), primary: real(top) };
|
|
71
|
+
}
|
|
72
|
+
let gitDir = null;
|
|
73
|
+
try {
|
|
74
|
+
const m = fs.readFileSync(dotGit, 'utf-8').match(/^gitdir:\s*(.+)$/m);
|
|
75
|
+
if (m) gitDir = real(path.resolve(top, m[1].trim()));
|
|
76
|
+
} catch { /* unreadable .git file */ }
|
|
77
|
+
return { ...base, isRepo: true, linked: true, toplevel: real(top), gitDir };
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
function gitContext(root) {
|
|
81
|
+
const triple = revParseTriple(root);
|
|
82
|
+
if (!triple) return fallbackContext(root);
|
|
83
|
+
const [toplevel, gitDir, commonDir] = triple;
|
|
84
|
+
|
|
85
|
+
let primary = null;
|
|
86
|
+
const wt = worktreeList(root);
|
|
87
|
+
if (wt && wt.length) primary = real(wt[0].path);
|
|
88
|
+
else if (path.basename(commonDir) === '.git') primary = path.dirname(commonDir);
|
|
89
|
+
|
|
90
|
+
const branchOut = git(root, ['symbolic-ref', '--short', '-q', 'HEAD']);
|
|
91
|
+
const branch = branchOut ? branchOut.trim() || null : null;
|
|
92
|
+
const headOut = git(root, ['rev-parse', '--short', 'HEAD']);
|
|
93
|
+
const head = headOut ? headOut.trim() || null : null;
|
|
94
|
+
|
|
95
|
+
return {
|
|
96
|
+
isRepo: true,
|
|
97
|
+
toplevel,
|
|
98
|
+
gitDir,
|
|
99
|
+
commonDir,
|
|
100
|
+
linked: gitDir !== commonDir,
|
|
101
|
+
primary,
|
|
102
|
+
branch,
|
|
103
|
+
detached: branch === null,
|
|
104
|
+
head
|
|
105
|
+
};
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
function localBranchExists(root, name) {
|
|
109
|
+
return git(root, ['rev-parse', '--verify', '-q', `refs/heads/${name}`]) !== null;
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
// origin/HEAD target, else the first existing local branch among
|
|
113
|
+
// init.defaultBranch, main, master; null when none exists.
|
|
114
|
+
function defaultBranch(root) {
|
|
115
|
+
const head = git(root, ['symbolic-ref', '--short', '-q', 'refs/remotes/origin/HEAD']);
|
|
116
|
+
if (head && head.trim()) return head.trim().replace(/^origin\//, '');
|
|
117
|
+
const configured = git(root, ['config', 'init.defaultBranch']);
|
|
118
|
+
const candidates = [configured && configured.trim(), 'main', 'master'].filter(Boolean);
|
|
119
|
+
for (const name of candidates) {
|
|
120
|
+
if (localBranchExists(root, name)) return name;
|
|
121
|
+
}
|
|
122
|
+
return null;
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
// Absolute paths of every tracked file in the repository that contains `root`
|
|
126
|
+
// (listed from the toplevel, so a project in a monorepo subdirectory still sees
|
|
127
|
+
// tracked files elsewhere in the repo that a symlink could lead to; paths are
|
|
128
|
+
// resolved through the real toplevel, so symlinked checkouts compare equal). A submodule
|
|
129
|
+
// (gitlink) is tracked as one entry, so `has()` also answers true for any path
|
|
130
|
+
// inside one: a write there would change another repository's tracked files.
|
|
131
|
+
// An empty Set means a repo with nothing tracked; null means git failed
|
|
132
|
+
// (missing binary, "dubious ownership"), so the caller must not assume
|
|
133
|
+
// nothing is tracked.
|
|
134
|
+
class TrackedPaths extends Set {
|
|
135
|
+
constructor() {
|
|
136
|
+
super();
|
|
137
|
+
this.gitlinks = [];
|
|
138
|
+
}
|
|
139
|
+
|
|
140
|
+
has(abs) {
|
|
141
|
+
if (super.has(abs)) return true;
|
|
142
|
+
return this.gitlinks.some(g => abs.startsWith(g + path.sep));
|
|
143
|
+
}
|
|
144
|
+
}
|
|
145
|
+
|
|
146
|
+
function trackedSet(root) {
|
|
147
|
+
const top = git(root, ['rev-parse', '--show-toplevel']);
|
|
148
|
+
if (top === null || !top.trim()) return null;
|
|
149
|
+
const toplevel = path.resolve(root, top.trim());
|
|
150
|
+
const out = git(toplevel, ['ls-files', '-s', '-z', '--full-name'], { timeout: 15000 });
|
|
151
|
+
if (out === null) return null;
|
|
152
|
+
const base = real(toplevel);
|
|
153
|
+
const set = new TrackedPaths();
|
|
154
|
+
for (const entry of out.split('\0')) {
|
|
155
|
+
const tab = entry.indexOf('\t');
|
|
156
|
+
if (tab < 0) continue;
|
|
157
|
+
const abs = path.resolve(base, entry.slice(tab + 1));
|
|
158
|
+
set.add(abs);
|
|
159
|
+
if (entry.startsWith('160000 ')) set.gitlinks.push(abs);
|
|
160
|
+
}
|
|
161
|
+
return set;
|
|
162
|
+
}
|
|
163
|
+
|
|
164
|
+
// Tracked files (paths relative to `root`) under the given pathspecs; empty
|
|
165
|
+
// when not a repo or git fails.
|
|
166
|
+
function trackedUnder(root, pathspecs) {
|
|
167
|
+
if (!pathspecs || !pathspecs.length) return [];
|
|
168
|
+
const out = git(root, ['ls-files', '-z', '--', ...pathspecs], { timeout: 15000 });
|
|
169
|
+
return out ? out.split('\0').filter(Boolean) : [];
|
|
170
|
+
}
|
|
171
|
+
|
|
172
|
+
// Absolute paths of tracked files with uncommitted changes (staged or not),
|
|
173
|
+
// listed from the repository toplevel so a tracked symlinked output directory
|
|
174
|
+
// that leads elsewhere in the repo is covered too. Null when git fails.
|
|
175
|
+
function dirtyTrackedSet(root) {
|
|
176
|
+
const top = git(root, ['rev-parse', '--show-toplevel']);
|
|
177
|
+
if (top === null || !top.trim()) return null;
|
|
178
|
+
const toplevel = real(path.resolve(root, top.trim()));
|
|
179
|
+
const out = git(toplevel, ['status', '--porcelain', '-z', '--untracked-files=no'], { timeout: 15000 });
|
|
180
|
+
if (out === null) return null;
|
|
181
|
+
const set = new Set();
|
|
182
|
+
const parts = out.split('\0');
|
|
183
|
+
for (let i = 0; i < parts.length; i++) {
|
|
184
|
+
const entry = parts[i];
|
|
185
|
+
if (entry.length < 4) continue;
|
|
186
|
+
set.add(path.resolve(toplevel, entry.slice(3)));
|
|
187
|
+
if (/[RC]/.test(entry.slice(0, 2))) i++; // a rename/copy carries its source as the next entry
|
|
188
|
+
}
|
|
189
|
+
return set;
|
|
190
|
+
}
|
|
191
|
+
|
|
192
|
+
function hasRemote(root, name = 'origin') {
|
|
193
|
+
const out = git(root, ['remote']);
|
|
194
|
+
return !!out && out.split('\n').map(s => s.trim()).includes(name);
|
|
195
|
+
}
|
|
196
|
+
|
|
197
|
+
// Lines of `git status --porcelain` (limited to `paths` when given); null when
|
|
198
|
+
// git is unavailable or fails.
|
|
199
|
+
function statusPorcelain(root, paths = []) {
|
|
200
|
+
const out = git(root, ['status', '--porcelain', ...(paths.length ? ['--', ...paths] : [])], { timeout: 15000 });
|
|
201
|
+
if (out === null) return null;
|
|
202
|
+
return out.split('\n').filter(Boolean);
|
|
203
|
+
}
|
|
204
|
+
|
|
205
|
+
// `git config user.name` as seen from `root`; null when unset.
|
|
206
|
+
function configUserName(root) {
|
|
207
|
+
const out = git(root, ['config', 'user.name']);
|
|
208
|
+
return out && out.trim() ? out.trim() : null;
|
|
209
|
+
}
|
|
210
|
+
|
|
211
|
+
// Whether `ref` resolves to a commit.
|
|
212
|
+
function refExists(root, ref) {
|
|
213
|
+
return git(root, ['rev-parse', '--verify', '-q', `${ref}^{commit}`]) !== null;
|
|
214
|
+
}
|
|
215
|
+
|
|
216
|
+
module.exports = {
|
|
217
|
+
gitContext, defaultBranch, trackedSet, dirtyTrackedSet, trackedUnder, hasRemote, worktreeList, statusPorcelain, configUserName,
|
|
218
|
+
localBranchExists, refExists
|
|
219
|
+
};
|
|
@@ -0,0 +1,211 @@
|
|
|
1
|
+
// Mutating and network git/gh operations used by `update`. Every wrapper uses
|
|
2
|
+
// execFileSync (no shell), never throws, and returns { ok, out, err } so the
|
|
3
|
+
// caller decides what a failure means. Read-only facts live in git-context.js.
|
|
4
|
+
|
|
5
|
+
'use strict';
|
|
6
|
+
|
|
7
|
+
const fs = require('node:fs');
|
|
8
|
+
const path = require('node:path');
|
|
9
|
+
const { execFileSync } = require('node:child_process');
|
|
10
|
+
const { defaultBranch, gitContext } = require('./git-context.js');
|
|
11
|
+
const { real } = require('./util.js');
|
|
12
|
+
|
|
13
|
+
function run(cmd, cwd, args, { timeout = 60000, env = null } = {}) {
|
|
14
|
+
try {
|
|
15
|
+
const out = execFileSync(cmd, args, {
|
|
16
|
+
cwd,
|
|
17
|
+
timeout,
|
|
18
|
+
encoding: 'utf-8',
|
|
19
|
+
maxBuffer: 256 * 1024 * 1024,
|
|
20
|
+
env: { ...process.env, ...(env || {}) },
|
|
21
|
+
stdio: ['ignore', 'pipe', 'pipe']
|
|
22
|
+
});
|
|
23
|
+
return { ok: true, out, err: '' };
|
|
24
|
+
} catch (e) {
|
|
25
|
+
const out = e && e.stdout ? String(e.stdout) : '';
|
|
26
|
+
const err = [e && e.stderr ? String(e.stderr) : '', out].join('').trim() || (e && e.message) || 'failed';
|
|
27
|
+
return { ok: false, out, err };
|
|
28
|
+
}
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
const git = (cwd, args, opts) => run('git', cwd, args, opts);
|
|
32
|
+
const NO_PROMPT = { GIT_TERMINAL_PROMPT: '0' };
|
|
33
|
+
|
|
34
|
+
// ---- reads against a ref -------------------------------------------------
|
|
35
|
+
|
|
36
|
+
// Content of `rel` (relative to cwd) at `ref`; null when absent or not a
|
|
37
|
+
// regular file. A symlink blob would read back as its target text and a
|
|
38
|
+
// gitlink is a submodule, so neither is ever treated as file content.
|
|
39
|
+
function treeMode(cwd, ref, rel) {
|
|
40
|
+
const t = git(cwd, ['ls-tree', '-z', ref, '--', rel]);
|
|
41
|
+
if (!t.ok) return null;
|
|
42
|
+
const entry = t.out.split('\0').filter(Boolean)[0];
|
|
43
|
+
return entry ? entry.split(' ')[0] : null;
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
// True when `rel` exists at `ref` but is not a regular file (symlink, gitlink).
|
|
47
|
+
function isSpecialAt(cwd, ref, rel) {
|
|
48
|
+
const mode = treeMode(cwd, ref, rel);
|
|
49
|
+
return mode !== null && mode !== '100644' && mode !== '100755';
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
function showFile(cwd, ref, rel) {
|
|
53
|
+
if (treeMode(cwd, ref, rel) === null || isSpecialAt(cwd, ref, rel)) return null;
|
|
54
|
+
const r = git(cwd, ['show', `${ref}:./${rel}`]);
|
|
55
|
+
return r.ok ? r.out : null;
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
// Files tracked at `ref` under `paths` (relative to cwd, no leading `./`).
|
|
59
|
+
function lsTree(cwd, ref, paths) {
|
|
60
|
+
const r = git(cwd, ['ls-tree', '-r', '--name-only', '-z', ref, '--', ...paths]);
|
|
61
|
+
return r.ok ? r.out.split('\0').filter(Boolean) : [];
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
// Files in the index under `paths`.
|
|
65
|
+
function lsFiles(cwd, paths) {
|
|
66
|
+
const r = git(cwd, ['ls-files', '-z', '--', ...paths]);
|
|
67
|
+
return r.ok ? r.out.split('\0').filter(Boolean) : [];
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
// Paths (relative to the repository top level) that differ between two commits.
|
|
71
|
+
// null when git fails.
|
|
72
|
+
function diffNames(cwd, from, to) {
|
|
73
|
+
const r = git(cwd, ['diff', '--name-only', '--no-renames', '-z', from, to]);
|
|
74
|
+
return r.ok ? r.out.split('\0').filter(Boolean) : null;
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
function headSha(cwd) {
|
|
78
|
+
const r = git(cwd, ['rev-parse', 'HEAD']);
|
|
79
|
+
return r.ok ? r.out.trim() : null;
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
// ---- guards --------------------------------------------------------------
|
|
83
|
+
|
|
84
|
+
// Hard guard: throws when `cwd` is checked out on the default branch.
|
|
85
|
+
function assertNotDefaultBranch(cwd) {
|
|
86
|
+
const ctx = gitContext(cwd);
|
|
87
|
+
if (!ctx.isRepo) return;
|
|
88
|
+
const def = defaultBranch(cwd);
|
|
89
|
+
if (ctx.branch && (ctx.branch === def || ctx.branch === 'main' || ctx.branch === 'master')) {
|
|
90
|
+
throw new Error(`refusing to commit on ${ctx.branch}`);
|
|
91
|
+
}
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
// ---- worktrees -----------------------------------------------------------
|
|
95
|
+
|
|
96
|
+
// A detached, fully checked-out worktree of `ref`. Commit hooks assume a
|
|
97
|
+
// working tree that matches the index (`git add -u` would stage the deletion of
|
|
98
|
+
// every file a sparse checkout left out, and tools such as the pre-commit
|
|
99
|
+
// framework read their config from disk), so the tree is complete. LFS
|
|
100
|
+
// objects are not downloaded (pointer files are checked out) and hooks are
|
|
101
|
+
// disabled for the checkout itself, since post-checkout can run arbitrary
|
|
102
|
+
// project code; the commit still runs the project's hooks.
|
|
103
|
+
function worktreeAdd(cwd, dir, ref) {
|
|
104
|
+
const env = { GIT_LFS_SKIP_SMUDGE: '1' };
|
|
105
|
+
return git(cwd, ['-c', 'core.hooksPath=/dev/null', 'worktree', 'add', '--detach', '-q', dir, ref], { timeout: 300000, env });
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
function worktreeRemove(cwd, dir) {
|
|
109
|
+
return git(cwd, ['worktree', 'remove', '--force', dir]);
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
// Drops the admin data of the worktree at `dir` only, when `worktree remove`
|
|
113
|
+
// could not. A repo-wide `git worktree prune` would also delete the entries of
|
|
114
|
+
// unrelated worktrees whose directory is momentarily missing (an unmounted
|
|
115
|
+
// volume, a directory moved by hand).
|
|
116
|
+
function worktreeForget(cwd, dir) {
|
|
117
|
+
const common = git(cwd, ['rev-parse', '--path-format=absolute', '--git-common-dir']);
|
|
118
|
+
if (!common.ok) return;
|
|
119
|
+
const admin = path.join(common.out.trim(), 'worktrees');
|
|
120
|
+
const wanted = real(path.join(dir, '.git'));
|
|
121
|
+
let names;
|
|
122
|
+
try { names = fs.readdirSync(admin); } catch { return; }
|
|
123
|
+
for (const name of names) {
|
|
124
|
+
try {
|
|
125
|
+
const recorded = fs.readFileSync(path.join(admin, name, 'gitdir'), 'utf-8').trim();
|
|
126
|
+
if (real(recorded) === wanted) fs.rmSync(path.join(admin, name), { recursive: true, force: true });
|
|
127
|
+
} catch { /* not ours or unreadable: leave it */ }
|
|
128
|
+
}
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
// ---- index and history ---------------------------------------------------
|
|
132
|
+
|
|
133
|
+
function add(cwd, paths) {
|
|
134
|
+
return git(cwd, ['add', '--', ...paths]);
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
function rmCached(cwd, paths) {
|
|
138
|
+
return git(cwd, ['rm', '--cached', '-q', '--', ...paths]);
|
|
139
|
+
}
|
|
140
|
+
|
|
141
|
+
// Never passes --no-verify: the project's hooks decide.
|
|
142
|
+
function commit(cwd, subject, body) {
|
|
143
|
+
assertNotDefaultBranch(cwd);
|
|
144
|
+
return git(cwd, ['commit', '-q', '-m', subject, ...(body ? ['-m', body] : [])], { timeout: 300000 });
|
|
145
|
+
}
|
|
146
|
+
|
|
147
|
+
function branchCreate(cwd, name, sha) {
|
|
148
|
+
return git(cwd, ['branch', name, sha]);
|
|
149
|
+
}
|
|
150
|
+
|
|
151
|
+
// Compare-and-swap: fails unless the branch still points at `oldSha`.
|
|
152
|
+
function updateRef(cwd, branch, newSha, oldSha) {
|
|
153
|
+
return git(cwd, ['update-ref', `refs/heads/${branch}`, newSha, oldSha]);
|
|
154
|
+
}
|
|
155
|
+
|
|
156
|
+
// ---- remote --------------------------------------------------------------
|
|
157
|
+
|
|
158
|
+
function fetch(cwd, remote, branch) {
|
|
159
|
+
return git(cwd, ['fetch', '--quiet', remote, branch], { timeout: 30000, env: NO_PROMPT });
|
|
160
|
+
}
|
|
161
|
+
|
|
162
|
+
// Never forces.
|
|
163
|
+
function push(cwd, remote, branch) {
|
|
164
|
+
return git(cwd, ['push', '-u', remote, `refs/heads/${branch}:refs/heads/${branch}`], { timeout: 120000, env: NO_PROMPT });
|
|
165
|
+
}
|
|
166
|
+
|
|
167
|
+
// https://github.com/<owner>/<repo> for a GitHub origin (ssh or https); else null.
|
|
168
|
+
function originWebUrl(cwd) {
|
|
169
|
+
const r = git(cwd, ['remote', 'get-url', 'origin']);
|
|
170
|
+
if (!r.ok) return null;
|
|
171
|
+
const url = r.out.trim();
|
|
172
|
+
const m = url.match(/^(?:git@|ssh:\/\/git@|https?:\/\/(?:[^@/]+@)?)github\.com[:/]([^/\s]+)\/([^/\s]+?)(?:\.git)?\/?$/);
|
|
173
|
+
return m ? `https://github.com/${m[1]}/${m[2]}` : null;
|
|
174
|
+
}
|
|
175
|
+
|
|
176
|
+
// ---- gh ------------------------------------------------------------------
|
|
177
|
+
|
|
178
|
+
function ghAvailable() {
|
|
179
|
+
return run('gh', process.cwd(), ['--version'], { timeout: 10000 }).ok;
|
|
180
|
+
}
|
|
181
|
+
|
|
182
|
+
// { url, state } of the PR for `branch`, or null.
|
|
183
|
+
function ghPrView(cwd, branch) {
|
|
184
|
+
const r = run('gh', cwd, ['pr', 'view', branch, '--json', 'url,state'], { timeout: 30000, env: NO_PROMPT });
|
|
185
|
+
if (!r.ok) return null;
|
|
186
|
+
try {
|
|
187
|
+
const data = JSON.parse(r.out);
|
|
188
|
+
return data && data.url ? { url: data.url, state: data.state || null } : null;
|
|
189
|
+
} catch {
|
|
190
|
+
return null;
|
|
191
|
+
}
|
|
192
|
+
}
|
|
193
|
+
|
|
194
|
+
// Returns { ok, url, err }.
|
|
195
|
+
function ghPrCreate(cwd, { base, head, title, body }) {
|
|
196
|
+
const r = run('gh', cwd, ['pr', 'create', '--base', base, '--head', head, '--title', title, '--body', body], { timeout: 60000, env: NO_PROMPT });
|
|
197
|
+
if (!r.ok) return { ok: false, url: null, err: r.err };
|
|
198
|
+
const url = r.out.split('\n').map(s => s.trim()).filter(Boolean).pop() || null;
|
|
199
|
+
return { ok: true, url, err: '' };
|
|
200
|
+
}
|
|
201
|
+
|
|
202
|
+
// Best-effort removal of a temp directory a failed `worktree remove` left behind.
|
|
203
|
+
function rmDir(dir) {
|
|
204
|
+
try { fs.rmSync(dir, { recursive: true, force: true }); } catch { /* ignore */ }
|
|
205
|
+
}
|
|
206
|
+
|
|
207
|
+
module.exports = {
|
|
208
|
+
run, showFile, isSpecialAt, lsTree, lsFiles, diffNames, headSha, assertNotDefaultBranch,
|
|
209
|
+
worktreeAdd, worktreeRemove, worktreeForget, add, rmCached, commit, branchCreate, updateRef,
|
|
210
|
+
fetch, push, originWebUrl, ghAvailable, ghPrView, ghPrCreate, rmDir
|
|
211
|
+
};
|