devlyn-cli 4.3.12 → 4.3.14
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/bin/cleanup-history.json +499 -1
- package/bin/devlyn.js +240 -36
- package/bin/skill-history.json +3 -0
- package/config/skills/_shared/task-complete.py +81 -19
- package/config/skills/_shared/task-completion.md +3 -1
- package/native/claude/.claude-plugin/plugin.json +1 -1
- package/native/claude/skills/_shared/task-complete.py +81 -19
- package/native/claude/skills/_shared/task-completion.md +3 -1
- package/native/codex/.agents/plugins/marketplace.json +1 -1
- package/native/codex/plugins/devlyn/.codex-plugin/plugin.json +1 -1
- package/native/codex/plugins/devlyn/skills/_shared/task-complete.py +81 -19
- package/native/codex/plugins/devlyn/skills/_shared/task-completion.md +3 -1
- package/node_modules/jsonc-parser/CHANGELOG.md +76 -0
- package/node_modules/jsonc-parser/LICENSE.md +21 -0
- package/node_modules/jsonc-parser/README.md +364 -0
- package/node_modules/jsonc-parser/SECURITY.md +41 -0
- package/node_modules/jsonc-parser/lib/esm/impl/edit.js +185 -0
- package/node_modules/jsonc-parser/lib/esm/impl/format.js +261 -0
- package/node_modules/jsonc-parser/lib/esm/impl/parser.js +659 -0
- package/node_modules/jsonc-parser/lib/esm/impl/scanner.js +443 -0
- package/node_modules/jsonc-parser/lib/esm/impl/string-intern.js +29 -0
- package/node_modules/jsonc-parser/lib/esm/main.d.ts +351 -0
- package/node_modules/jsonc-parser/lib/esm/main.js +178 -0
- package/node_modules/jsonc-parser/lib/umd/impl/edit.js +201 -0
- package/node_modules/jsonc-parser/lib/umd/impl/format.js +275 -0
- package/node_modules/jsonc-parser/lib/umd/impl/parser.js +682 -0
- package/node_modules/jsonc-parser/lib/umd/impl/scanner.js +456 -0
- package/node_modules/jsonc-parser/lib/umd/impl/string-intern.js +42 -0
- package/node_modules/jsonc-parser/lib/umd/main.d.ts +351 -0
- package/node_modules/jsonc-parser/lib/umd/main.js +194 -0
- package/node_modules/jsonc-parser/package.json +37 -0
- package/package.json +3 -1
- package/release.json +110 -15
package/bin/devlyn.js
CHANGED
|
@@ -5,6 +5,7 @@ const path = require('path');
|
|
|
5
5
|
const os = require('os');
|
|
6
6
|
const crypto = require('crypto');
|
|
7
7
|
const { execSync, spawnSync } = require('child_process');
|
|
8
|
+
const { parseTree, findNodeAtLocation, applyEdits } = require('jsonc-parser');
|
|
8
9
|
|
|
9
10
|
const CONFIG_SOURCE = path.join(__dirname, '..', 'config');
|
|
10
11
|
const OPTIONAL_SKILLS_SOURCE = path.join(__dirname, '..', 'optional-skills');
|
|
@@ -887,8 +888,8 @@ function replaceFile(file, text) {
|
|
|
887
888
|
}
|
|
888
889
|
}
|
|
889
890
|
|
|
890
|
-
function cleanRetiredSettings(settings) {
|
|
891
|
-
let changed =
|
|
891
|
+
function cleanRetiredSettings(settings, hookScripts = []) {
|
|
892
|
+
let changed = cleanJarvisSettings(settings, hookScripts);
|
|
892
893
|
// Remove exactly the retired Stop hook command, and its entry only when nothing else is left in it.
|
|
893
894
|
const retiredHook = (hook) => hook?.type === 'command' && hook.command === RETIRED_STOP_HOOK;
|
|
894
895
|
const stop = Array.isArray(settings.hooks?.Stop) ? settings.hooks.Stop : [];
|
|
@@ -903,6 +904,91 @@ function cleanRetiredSettings(settings) {
|
|
|
903
904
|
return changed;
|
|
904
905
|
}
|
|
905
906
|
|
|
907
|
+
// A hook's event, matcher and complete command object must be shipped bytes (JSON key order aside).
|
|
908
|
+
// Extra owner hooks and wrapper keys survive. Script names alone never authorize a settings edit.
|
|
909
|
+
function cleanJarvisSettings(settings, scripts) {
|
|
910
|
+
let changed = false;
|
|
911
|
+
for (const [event, entries] of Object.entries(settings?.hooks || {})) {
|
|
912
|
+
if (!Array.isArray(entries)) continue;
|
|
913
|
+
for (let i = entries.length - 1; i >= 0; i--) {
|
|
914
|
+
const entry = entries[i];
|
|
915
|
+
if (!Array.isArray(entry?.hooks)) continue;
|
|
916
|
+
const kept = entry.hooks.filter((hook) => {
|
|
917
|
+
const hash = crypto.createHash('sha256').update(cleanupCanonical([event, entry.matcher, hook])).digest('hex');
|
|
918
|
+
return !scripts.some((script) => CLEANUP_HISTORY.hooks[script]?.entries.includes(hash));
|
|
919
|
+
});
|
|
920
|
+
if (kept.length === entry.hooks.length) continue;
|
|
921
|
+
changed = true;
|
|
922
|
+
entry.hooks = kept;
|
|
923
|
+
if (!kept.length && Object.keys(entry).every((key) => ['matcher', 'hooks'].includes(key))) entries.splice(i, 1);
|
|
924
|
+
}
|
|
925
|
+
}
|
|
926
|
+
return changed;
|
|
927
|
+
}
|
|
928
|
+
|
|
929
|
+
function cleanupHookReferenced(settings, script) {
|
|
930
|
+
// Conservative even for shell wrappers, absolute paths, Windows separators and malformed entries.
|
|
931
|
+
return JSON.stringify(settings).toLowerCase().includes(path.posix.basename(script).toLowerCase());
|
|
932
|
+
}
|
|
933
|
+
|
|
934
|
+
function cleanupReadSettings(base, name = 'settings.json') {
|
|
935
|
+
const file = path.join(base, '.claude', name);
|
|
936
|
+
if (!cleanupRealParents(base, file)) return { safe: false };
|
|
937
|
+
const stat = fs.lstatSync(file, { throwIfNoEntry: false });
|
|
938
|
+
if (!stat) return { safe: true, settings: {} };
|
|
939
|
+
if (!stat.isFile() || stat.nlink !== 1) return { safe: false };
|
|
940
|
+
try {
|
|
941
|
+
const text = new TextDecoder('utf-8', { fatal: true, ignoreBOM: true }).decode(fs.readFileSync(file));
|
|
942
|
+
const settings = JSON.parse(text.replace(/^\uFEFF/, ''));
|
|
943
|
+
const tree = parseTree(text.replace(/^\uFEFF/, ' '));
|
|
944
|
+
const hooks = tree.children?.filter((node) => node.children?.[0]?.value === 'hooks') || [];
|
|
945
|
+
const duplicates = (node) => {
|
|
946
|
+
if (node.type === 'object') {
|
|
947
|
+
const names = node.children.map((property) => property.children[0].value);
|
|
948
|
+
if (new Set(names).size !== names.length) return true;
|
|
949
|
+
}
|
|
950
|
+
return node.children?.some(duplicates) || false;
|
|
951
|
+
};
|
|
952
|
+
// Duplicate owner keys elsewhere survive; duplicate hook paths make a surgical edit ambiguous.
|
|
953
|
+
if (hooks.length > 1 || (hooks.length && duplicates(hooks[0]))) return { safe: false };
|
|
954
|
+
return settings && typeof settings === 'object' && !Array.isArray(settings)
|
|
955
|
+
? { safe: true, settings, text } : { safe: false };
|
|
956
|
+
} catch (error) {
|
|
957
|
+
if (error instanceof SyntaxError || error.code === 'ERR_ENCODING_INVALID_ENCODED_DATA') return { safe: false };
|
|
958
|
+
throw error;
|
|
959
|
+
}
|
|
960
|
+
}
|
|
961
|
+
|
|
962
|
+
function cleanupSettingsText(text, settings, scripts) {
|
|
963
|
+
const before = Object.entries(settings.hooks || {}).flatMap(([event, entries]) => !Array.isArray(entries) ? []
|
|
964
|
+
: entries.map((entry, index) => ({ event, entries, entry, index, hooks: Array.isArray(entry?.hooks) ? [...entry.hooks] : [] })));
|
|
965
|
+
if (!cleanRetiredSettings(settings, scripts)) return text;
|
|
966
|
+
const removals = [];
|
|
967
|
+
for (const { event, entries, entry, index, hooks } of before.reverse()) {
|
|
968
|
+
const location = ['hooks', event, index];
|
|
969
|
+
if (!entries.includes(entry)) removals.push(location);
|
|
970
|
+
else for (let i = hooks.length - 1; i >= 0; i--) if (!entry.hooks.includes(hooks[i])) removals.push([...location, 'hooks', i]);
|
|
971
|
+
}
|
|
972
|
+
// Only prune containers this cleanup emptied; pre-existing owner empties are not leftovers.
|
|
973
|
+
const emptiedEvents = [...new Set(before.map(({ event }) => event))].filter((event) => settings.hooks[event].length === 0);
|
|
974
|
+
removals.push(...emptiedEvents.map((event) => ['hooks', event]));
|
|
975
|
+
if (emptiedEvents.length === Object.keys(settings.hooks).length) removals.push(['hooks']);
|
|
976
|
+
// Remove array elements and object properties by AST ranges, including the adjacent comma, without serializing owner
|
|
977
|
+
// values. jsonc-parser 3.3.1's modify() truncates a last-element deletion without formatting.
|
|
978
|
+
for (const location of removals) {
|
|
979
|
+
let node = findNodeAtLocation(parseTree(text.replace(/^\uFEFF/, ' ')), location);
|
|
980
|
+
if (node?.parent?.type === 'property') node = node.parent;
|
|
981
|
+
if (!node || !['array', 'object'].includes(node.parent?.type)) throw new Error('Settings edit path changed');
|
|
982
|
+
const siblings = node.parent.children, index = siblings.indexOf(node);
|
|
983
|
+
const previous = siblings[index - 1], next = siblings[index + 1];
|
|
984
|
+
const offset = previous ? previous.offset + previous.length : node.offset;
|
|
985
|
+
const end = !previous && next ? next.offset : node.offset + node.length;
|
|
986
|
+
text = applyEdits(text, [{ offset, length: end - offset, content: '' }]);
|
|
987
|
+
}
|
|
988
|
+
const after = JSON.parse(text.replace(/^\uFEFF/, '')); // Refuse an invalid edit before changing any bytes.
|
|
989
|
+
return Object.keys(after).length === 0 ? null : text;
|
|
990
|
+
}
|
|
991
|
+
|
|
906
992
|
// Shared user roots need one writer from ownership checks through the last addon write.
|
|
907
993
|
// Never reap a foreign lock: PID reuse and slow installs make age/liveness unsafe evidence.
|
|
908
994
|
const heldSkillLocks = new Set();
|
|
@@ -1545,20 +1631,22 @@ function cleanupSkillRoot(base, root, current) {
|
|
|
1545
1631
|
}
|
|
1546
1632
|
|
|
1547
1633
|
function cleanupCommands(base) {
|
|
1548
|
-
|
|
1549
|
-
|
|
1550
|
-
|
|
1551
|
-
|
|
1552
|
-
|
|
1553
|
-
|
|
1554
|
-
|
|
1555
|
-
|
|
1556
|
-
|
|
1557
|
-
|
|
1558
|
-
|
|
1634
|
+
return CLEANUP_HISTORY.commandDirectories.flatMap((directory) => {
|
|
1635
|
+
const commands = path.join(base, directory);
|
|
1636
|
+
if (!cleanupRealParents(base, path.join(commands, 'x')) || !fs.lstatSync(commands, { throwIfNoEntry: false })?.isDirectory()) return [];
|
|
1637
|
+
const names = new Set([...DEPRECATED_FILES.map((file) => path.basename(file).toLowerCase()), ...Object.keys(CLEANUP_HISTORY.commands)]);
|
|
1638
|
+
return fs.readdirSync(commands).sort().filter((name) => names.has(name.toLowerCase())
|
|
1639
|
+
&& fs.lstatSync(path.join(commands, name)).isFile())
|
|
1640
|
+
.map((name) => {
|
|
1641
|
+
const file = path.join(commands, name);
|
|
1642
|
+
const proven = CLEANUP_HISTORY.commands[name.toLowerCase()]?.includes(contentHash(file));
|
|
1643
|
+
return { kind: 'legacy-command', path: file, base, action: proven ? 'remove' : 'skip',
|
|
1644
|
+
reasons: proven ? [] : [cleanupReason('modified-copy')] };
|
|
1645
|
+
});
|
|
1646
|
+
});
|
|
1559
1647
|
}
|
|
1560
1648
|
|
|
1561
|
-
function cleanupProjectFiles(base) {
|
|
1649
|
+
function cleanupProjectFiles(base, includedHooks = new Set()) {
|
|
1562
1650
|
const items = [];
|
|
1563
1651
|
const hash = (file) => crypto.createHash('sha256')
|
|
1564
1652
|
.update(fs.readFileSync(file).toString('latin1').replace(/\r\n/g, '\n'), 'latin1').digest('hex');
|
|
@@ -1569,14 +1657,41 @@ function cleanupProjectFiles(base) {
|
|
|
1569
1657
|
}
|
|
1570
1658
|
}
|
|
1571
1659
|
const settingsFile = path.join(base, '.claude', 'settings.json');
|
|
1572
|
-
|
|
1573
|
-
|
|
1574
|
-
|
|
1575
|
-
|
|
1576
|
-
|
|
1577
|
-
|
|
1578
|
-
|
|
1579
|
-
|
|
1660
|
+
const { safe, settings } = cleanupReadSettings(base);
|
|
1661
|
+
const localSettings = cleanupReadSettings(base, 'settings.local.json');
|
|
1662
|
+
const hookScripts = [], hookCandidates = [];
|
|
1663
|
+
for (const [name, history] of Object.entries(CLEANUP_HISTORY.hooks)) {
|
|
1664
|
+
const file = path.join(base, name);
|
|
1665
|
+
const stat = fs.lstatSync(file, { throwIfNoEntry: false });
|
|
1666
|
+
if (!stat) continue;
|
|
1667
|
+
const item = { kind: 'legacy-hook', path: file, base, action: 'skip', reasons: [cleanupReason('outside-link')] };
|
|
1668
|
+
items.push(item);
|
|
1669
|
+
if (!cleanupRealParents(base, file) || !stat.isFile() || stat.nlink !== 1) continue;
|
|
1670
|
+
const proven = history.hashes.includes(contentHash(file));
|
|
1671
|
+
Object.assign(item, { action: proven ? 'remove' : 'skip', reasons: proven ? [] : [cleanupReason('modified-copy')] });
|
|
1672
|
+
if (!safe || !localSettings.safe || cleanupHookReferenced(localSettings.settings, name)) {
|
|
1673
|
+
Object.assign(item, { action: 'skip', reasons: [cleanupReason('dependent-copy')] });
|
|
1674
|
+
continue;
|
|
1675
|
+
}
|
|
1676
|
+
if (!cleanupHookReferenced(settings, name)) continue;
|
|
1677
|
+
const after = structuredClone(settings);
|
|
1678
|
+
cleanJarvisSettings(after, [name]);
|
|
1679
|
+
// Unknown commands/matchers/extra command keys retain the script. Routes cannot split a pair:
|
|
1680
|
+
// a PR leaves the caller's tracked copy in place until the base is taken.
|
|
1681
|
+
if (cleanupHookReferenced(after, name) || cleanupTracked(file) !== cleanupTracked(settingsFile)) {
|
|
1682
|
+
Object.assign(item, { action: 'skip', reasons: [cleanupReason('dependent-copy')] });
|
|
1683
|
+
continue;
|
|
1684
|
+
}
|
|
1685
|
+
item.hookSettings = settingsFile;
|
|
1686
|
+
hookCandidates.push(name);
|
|
1687
|
+
if (proven || includedHooks.has(file)) hookScripts.push(name);
|
|
1688
|
+
}
|
|
1689
|
+
const stop = Array.isArray(settings?.hooks?.Stop) ? settings.hooks.Stop : [];
|
|
1690
|
+
const retiredStop = stop.some((entry) => Array.isArray(entry?.hooks) && entry.hooks.some((hook) => hook?.type === 'command' && hook.command === RETIRED_STOP_HOOK));
|
|
1691
|
+
if (safe && (retiredStop || hookCandidates.length)) {
|
|
1692
|
+
const edit = retiredStop || hookScripts.length > 0;
|
|
1693
|
+
items.push({ kind: 'settings-entry', path: settingsFile, base, action: edit ? 'edit' : 'skip',
|
|
1694
|
+
reasons: edit ? [] : [cleanupReason('dependent-copy')], hookScripts, hookCandidates });
|
|
1580
1695
|
}
|
|
1581
1696
|
return items;
|
|
1582
1697
|
}
|
|
@@ -1690,7 +1805,7 @@ function cleanupRetainSupport(items) {
|
|
|
1690
1805
|
return items;
|
|
1691
1806
|
}
|
|
1692
1807
|
|
|
1693
|
-
function cleanupProjectScope(project, current, includeModified = false) {
|
|
1808
|
+
function cleanupProjectScope(project, current, includeModified = false, includedHooks = new Set()) {
|
|
1694
1809
|
const { repositoryIdentity } = require('./harness-status');
|
|
1695
1810
|
const { physicalRoot } = require('./release');
|
|
1696
1811
|
const root = physicalRoot(project);
|
|
@@ -1703,7 +1818,7 @@ function cleanupProjectScope(project, current, includeModified = false) {
|
|
|
1703
1818
|
if (cleanupSameFile(base, os.homedir())) return { ...scope, items: whole('home-folder') };
|
|
1704
1819
|
const items = [
|
|
1705
1820
|
...cleanupSkillRoots(['.agents', '.claude'].map((dir) => [base, path.join(base, dir, 'skills')]), current, includeModified),
|
|
1706
|
-
...cleanupCommands(base), ...cleanupProjectFiles(base), ...cleanupInstructions(base),
|
|
1821
|
+
...cleanupCommands(base), ...cleanupProjectFiles(base, includedHooks), ...cleanupInstructions(base),
|
|
1707
1822
|
];
|
|
1708
1823
|
const busy = identity && items.some((item) => cleanupAction(item, includeModified) !== 'skip') ? cleanupQueueBusy(base) : null;
|
|
1709
1824
|
for (const item of items) {
|
|
@@ -1739,13 +1854,13 @@ function cleanupMachineScope(home, accountHomes, current, includeModified = fals
|
|
|
1739
1854
|
return { kind: 'machine', root: home, name: null, remote: null, items: cleanupRetainSupport(items) };
|
|
1740
1855
|
}
|
|
1741
1856
|
|
|
1742
|
-
function cleanupScopes(inputs, current, includeModified = false) {
|
|
1857
|
+
function cleanupScopes(inputs, current, includeModified = false, includedHooks = new Set()) {
|
|
1743
1858
|
const { sha256 } = require('./release');
|
|
1744
1859
|
const scopes = [];
|
|
1745
1860
|
if (inputs.machine) scopes.push({ id: 'machine', ...cleanupMachineScope(os.homedir(), inputs.accountHomes, current, includeModified) });
|
|
1746
1861
|
for (const project of inputs.projects) {
|
|
1747
1862
|
let scope;
|
|
1748
|
-
try { scope = cleanupProjectScope(project, current, includeModified); }
|
|
1863
|
+
try { scope = cleanupProjectScope(project, current, includeModified, includedHooks); }
|
|
1749
1864
|
catch (error) {
|
|
1750
1865
|
scope = { kind: 'project', root: project, name: path.basename(project), remote: null, items: [{ kind: 'project', path: project, base: project,
|
|
1751
1866
|
relative: '', route: 'local', action: 'skip', reasons: [cleanupReason(error.reason?.code === 'repository-unreadable' ? 'repository-unreadable' : 'project-unreadable', { detail: error.message })] }] };
|
|
@@ -1789,17 +1904,30 @@ function cleanupDigest(plan) {
|
|
|
1789
1904
|
}
|
|
1790
1905
|
|
|
1791
1906
|
// Apply one item a fresh plan proves right now; returns a reason when it is not what was proven.
|
|
1792
|
-
function
|
|
1907
|
+
function cleanupValidateItem(item) {
|
|
1793
1908
|
const now = cleanupPreimage(item.path, Boolean(item.included));
|
|
1794
1909
|
if (now.type !== item.before.type || now.sha256 !== item.before.sha256) return cleanupReason('stale-plan');
|
|
1795
1910
|
if (!cleanupRealParents(item.base, item.path)) return cleanupReason('outside-link');
|
|
1911
|
+
return null;
|
|
1912
|
+
}
|
|
1913
|
+
|
|
1914
|
+
function cleanupApplyItem(item) {
|
|
1915
|
+
const invalid = cleanupValidateItem(item);
|
|
1916
|
+
if (invalid) return invalid;
|
|
1796
1917
|
if (cleanupAction(item) === 'remove') {
|
|
1918
|
+
if (item.kind === 'legacy-hook') {
|
|
1919
|
+
const { safe, settings } = cleanupReadSettings(item.base);
|
|
1920
|
+
if (!safe || cleanupHookReferenced(settings, item.relative)) return cleanupReason('dependent-copy');
|
|
1921
|
+
}
|
|
1797
1922
|
fs.rmSync(item.path, { recursive: true }); // lstat/realParents never follow a link; rm removes the entry itself.
|
|
1798
1923
|
return null;
|
|
1799
1924
|
}
|
|
1800
1925
|
if (item.kind === 'settings-entry') {
|
|
1801
|
-
const settings =
|
|
1802
|
-
if (
|
|
1926
|
+
const { safe, settings, text } = cleanupReadSettings(item.base);
|
|
1927
|
+
if (!safe || text === undefined) return cleanupReason('stale-plan');
|
|
1928
|
+
const after = cleanupSettingsText(text, settings, item.hookScripts);
|
|
1929
|
+
if (after === null) fs.unlinkSync(item.path);
|
|
1930
|
+
else if (after !== text) replaceFile(item.path, after);
|
|
1803
1931
|
return null;
|
|
1804
1932
|
}
|
|
1805
1933
|
if (item.kind === 'instructions') {
|
|
@@ -1822,11 +1950,56 @@ function cleanupProven(reviewed, fresh) {
|
|
|
1822
1950
|
const same = match && match.action === item.action && (!item.included || (cleanupCanInclude(match) && item.before.sha256 !== null))
|
|
1823
1951
|
&& match.relative === item.relative && match.route === item.route
|
|
1824
1952
|
&& match.before.type === item.before.type && match.before.sha256 === item.before.sha256
|
|
1953
|
+
&& cleanupCanonical(match.hookScripts || []) === cleanupCanonical(item.hookScripts || [])
|
|
1825
1954
|
&& !(cleanupSupport(item) && narrowed.has(path.dirname(item.path)));
|
|
1826
1955
|
return { reviewed: item, fresh: same ? { ...match, included: item.included } : null };
|
|
1827
1956
|
});
|
|
1828
1957
|
}
|
|
1829
1958
|
|
|
1959
|
+
// A narrowed or stale review may not split a settings edit from any script it disables.
|
|
1960
|
+
function cleanupCoupledPairs(pairs) {
|
|
1961
|
+
const byPath = new Map(pairs.map((pair) => [pair.reviewed.path, pair]));
|
|
1962
|
+
for (const pair of pairs.filter(({ reviewed }) => reviewed.kind === 'settings-entry')) {
|
|
1963
|
+
const scripts = (pair.reviewed.hookScripts || []).map((name) => byPath.get(path.join(pair.reviewed.base, name)));
|
|
1964
|
+
if (!pair.fresh || scripts.some((script) => !script?.fresh || cleanupAction(script.fresh) !== 'remove')) {
|
|
1965
|
+
pair.fresh = null;
|
|
1966
|
+
for (const script of scripts) if (script) script.fresh = null;
|
|
1967
|
+
}
|
|
1968
|
+
}
|
|
1969
|
+
for (const pair of pairs) {
|
|
1970
|
+
if (pair.reviewed.hookSettings && (!byPath.get(pair.reviewed.hookSettings)?.fresh
|
|
1971
|
+
|| !byPath.get(pair.reviewed.hookSettings).fresh.hookScripts.includes(pair.reviewed.relative))) pair.fresh = null;
|
|
1972
|
+
}
|
|
1973
|
+
return pairs;
|
|
1974
|
+
}
|
|
1975
|
+
|
|
1976
|
+
// Stage the scripts reversibly, then replace settings. A failed rename/write restores the scripts;
|
|
1977
|
+
// settings never references a script we permanently removed. Temp bytes are never source evidence.
|
|
1978
|
+
function cleanupApplyHookGroup(settings, scripts) {
|
|
1979
|
+
for (const item of [settings, ...scripts]) {
|
|
1980
|
+
const invalid = cleanupValidateItem(item);
|
|
1981
|
+
if (invalid) return invalid;
|
|
1982
|
+
}
|
|
1983
|
+
const staged = [];
|
|
1984
|
+
try {
|
|
1985
|
+
for (const item of scripts) {
|
|
1986
|
+
const temp = `${item.path}.${crypto.randomUUID()}.tmp`;
|
|
1987
|
+
fs.renameSync(item.path, temp);
|
|
1988
|
+
staged.push({ item, temp });
|
|
1989
|
+
}
|
|
1990
|
+
const error = cleanupApplyItem(settings);
|
|
1991
|
+
if (error) throw new Error(error.code);
|
|
1992
|
+
} catch (error) {
|
|
1993
|
+
const errors = [error];
|
|
1994
|
+
for (const { item, temp } of staged.reverse()) {
|
|
1995
|
+
try { fs.renameSync(temp, item.path); } catch (rollback) { errors.push(rollback); }
|
|
1996
|
+
}
|
|
1997
|
+
throw new AggregateError(errors, errors.map((e) => e.message).join('; '));
|
|
1998
|
+
}
|
|
1999
|
+
for (const { temp } of staged) fs.unlinkSync(temp);
|
|
2000
|
+
return null;
|
|
2001
|
+
}
|
|
2002
|
+
|
|
1830
2003
|
// Support entries (`_shared`, the marker) go last, and only when nothing beside them was refused.
|
|
1831
2004
|
function cleanupSupport(item) {
|
|
1832
2005
|
return path.basename(item.path) === '_shared' || item.kind === 'install-marker';
|
|
@@ -1855,7 +2028,16 @@ function cleanupLocal(scope, items) {
|
|
|
1855
2028
|
catch (error) { results.set(item.id, cleanupReason('apply-failed', { detail: error.message })); }
|
|
1856
2029
|
};
|
|
1857
2030
|
const run = () => {
|
|
1858
|
-
for (const
|
|
2031
|
+
for (const settings of items.filter((item) => item.hookScripts?.length)) {
|
|
2032
|
+
const scripts = items.filter((item) => item.hookSettings === settings.path);
|
|
2033
|
+
const group = [settings, ...scripts];
|
|
2034
|
+
let error;
|
|
2035
|
+
try {
|
|
2036
|
+
error = group.some((item) => cleanupTracked(item.path)) ? cleanupReason('tracked-copy') : cleanupApplyHookGroup(settings, scripts);
|
|
2037
|
+
} catch (failure) { error = cleanupReason('apply-failed', { detail: failure.message }); }
|
|
2038
|
+
for (const item of group) results.set(item.id, error);
|
|
2039
|
+
}
|
|
2040
|
+
for (const item of items.filter((item) => !cleanupSupport(item) && !results.has(item.id))) act(item);
|
|
1859
2041
|
for (const item of items.filter(cleanupSupport)) {
|
|
1860
2042
|
const refused = items.some((other) => !cleanupSupport(other) && path.dirname(other.path) === path.dirname(item.path)
|
|
1861
2043
|
&& results.get(other.id) !== null);
|
|
@@ -1885,13 +2067,17 @@ function cleanupDeliver(scope, items, current) {
|
|
|
1885
2067
|
if (!python) return { error: cleanupReason('requirement-missing', { name: 'Python 3.11' }) };
|
|
1886
2068
|
const run = (args, cwd) => spawnSync(python, ['-B', helper, ...args], { cwd, encoding: 'utf8', env: { ...process.env, PYTHONDONTWRITEBYTECODE: '1' } });
|
|
1887
2069
|
const lastJson = (text) => { try { return JSON.parse(text.trim().split('\n').at(-1)); } catch { return null; } };
|
|
2070
|
+
const failureCode = (reason) => /\b(authentication|unauthorized|gh auth login|could not read Username|HTTP 40[13])\b/i.test(reason) ? 'auth-failed' : 'delivery-failed';
|
|
1888
2071
|
const short = crypto.randomBytes(4).toString('hex');
|
|
1889
2072
|
const branch = `devlyn/cleanup-${short}`;
|
|
1890
2073
|
const worktree = path.join(path.dirname(scope.root), `${path.basename(scope.root)}.devlyn.noindex`, `devlyn-cleanup-${short}`);
|
|
1891
2074
|
const allocated = lastJson(run(['allocate', '--repo', scope.root, '--task', `devlyn-cli ${PKG.version} legacy cleanup`,
|
|
1892
2075
|
'--branch', branch, '--worktree', worktree, '--repository', scope.remote.repository,
|
|
1893
2076
|
'--remote', 'origin', '--base', scope.remote.base], scope.root).stdout);
|
|
1894
|
-
if (allocated?.status !== 'ALLOCATED')
|
|
2077
|
+
if (allocated?.status !== 'ALLOCATED') {
|
|
2078
|
+
const reason = allocated?.reason || 'allocation failed';
|
|
2079
|
+
return { error: cleanupReason(failureCode(reason), { detail: reason }) };
|
|
2080
|
+
}
|
|
1895
2081
|
const release = (error = null) => {
|
|
1896
2082
|
for (const args of [['worktree', 'remove', '--force', worktree], ['branch', '-D', branch]]) {
|
|
1897
2083
|
const result = cleanupGit(scope.root, args);
|
|
@@ -1926,8 +2112,7 @@ function cleanupDeliver(scope, items, current) {
|
|
|
1926
2112
|
if (status === 'COMPLETE' || status === 'MERGED') return { ...cleanupUpdateCheckout(scope), delivery: { state: 'merged', url: completed.pr || null } };
|
|
1927
2113
|
if (completed?.pr) return { delivery: { state: 'pr-open', url: completed.pr } };
|
|
1928
2114
|
const reason = completed?.reason || 'delivery did not complete';
|
|
1929
|
-
|
|
1930
|
-
return { error: cleanupReason(auth ? 'auth-failed' : 'delivery-failed', { detail: reason, ...(completed?.resume ? { resume: completed.resume } : {}) }) };
|
|
2115
|
+
return { error: cleanupReason(failureCode(reason), { detail: reason, ...(completed?.resume ? { resume: completed.resume } : {}) }) };
|
|
1931
2116
|
}
|
|
1932
2117
|
|
|
1933
2118
|
// With no fresh counterpart, the base settled an item only if it changed the path's content or
|
|
@@ -2001,7 +2186,8 @@ function cleanupUpdateCheckout(scope) {
|
|
|
2001
2186
|
// With every reviewed item already settled by the remote base, the worktree is released with
|
|
2002
2187
|
// nothing to deliver.
|
|
2003
2188
|
function cleanupDeliveryCommit(worktree, items, current, release, settledUpstream) {
|
|
2004
|
-
const
|
|
2189
|
+
const includedHooks = new Set(items.filter((item) => item.kind === 'legacy-hook' && item.included).map((item) => path.join(worktree, item.relative)));
|
|
2190
|
+
const fresh = cleanupProjectScope(worktree, current, items.some((item) => item.included), includedHooks);
|
|
2005
2191
|
const available = new Map(fresh.items.map((item) => [`${item.kind}\u0000${item.relative}`, item]));
|
|
2006
2192
|
const pairs = items.map((reviewed) => {
|
|
2007
2193
|
const fresh = available.get(`${reviewed.kind}\u0000${reviewed.relative}`);
|
|
@@ -2011,13 +2197,23 @@ function cleanupDeliveryCommit(worktree, items, current, release, settledUpstrea
|
|
|
2011
2197
|
? fresh.action !== reviewed.action || fresh.route !== 'delivery'
|
|
2012
2198
|
|| (reviewed.included && (!cleanupCanInclude(fresh) || before.sha256 === null))
|
|
2013
2199
|
|| before.type !== reviewed.before.type || before.sha256 !== reviewed.before.sha256
|
|
2200
|
+
|| cleanupCanonical(fresh.hookScripts || []) !== cleanupCanonical(reviewed.hookScripts || [])
|
|
2014
2201
|
: !settledUpstream(reviewed.relative)));
|
|
2015
2202
|
// Check the entire batch before mutating even one item; the remote base cannot replace the reviewed preimage.
|
|
2016
2203
|
if (stale.length) return release(cleanupReason('stale-plan', { detail: stale.map(({ reviewed }) => reviewed.relative).join(', ') }));
|
|
2204
|
+
if (cleanupCoupledPairs(pairs.filter(({ fresh }) => fresh)).some((pair) => !pair.fresh)) return release(cleanupReason('stale-plan'));
|
|
2017
2205
|
const present = pairs.filter(({ fresh }) => fresh);
|
|
2018
2206
|
if (!present.length) return release();
|
|
2019
2207
|
const applied = [];
|
|
2208
|
+
for (const { reviewed, fresh: settings } of present.filter(({ fresh }) => fresh.hookScripts?.length)) {
|
|
2209
|
+
const scripts = present.filter(({ fresh }) => fresh.hookSettings === settings.path)
|
|
2210
|
+
.map(({ reviewed, fresh }) => ({ ...fresh, before: reviewed.before, included: reviewed.included }));
|
|
2211
|
+
const error = cleanupApplyHookGroup({ ...settings, before: reviewed.before }, scripts);
|
|
2212
|
+
if (error) return release(error);
|
|
2213
|
+
applied.push(settings.relative, ...scripts.map((item) => item.relative));
|
|
2214
|
+
}
|
|
2020
2215
|
for (const { reviewed, fresh } of present) {
|
|
2216
|
+
if (applied.includes(fresh.relative)) continue;
|
|
2021
2217
|
const error = cleanupApplyItem({ ...fresh, before: reviewed.before, included: reviewed.included });
|
|
2022
2218
|
if (error) return release(cleanupReason(error.code, { ...error.params, detail: reviewed.relative }));
|
|
2023
2219
|
applied.push(fresh.relative);
|
|
@@ -2052,12 +2248,20 @@ function cleanupDeliveryCommit(worktree, items, current, release, settledUpstrea
|
|
|
2052
2248
|
function cleanupApply(plan, { events = false } = {}) {
|
|
2053
2249
|
const current = cleanupCurrentFingerprints();
|
|
2054
2250
|
const includeModified = plan.scopes.some((scope) => scope.items.some((item) => item.included));
|
|
2055
|
-
const
|
|
2251
|
+
const includedHooks = new Set(plan.scopes.flatMap((scope) => scope.items)
|
|
2252
|
+
.filter((item) => item.kind === 'legacy-hook' && item.included).map((item) => item.path));
|
|
2253
|
+
// Explicit selection of a reviewed edited script also selects its reviewed settings preimage.
|
|
2254
|
+
for (const scope of plan.scopes) for (const item of scope.items) {
|
|
2255
|
+
if (!item.hookCandidates || (item.action === 'skip' && item.reasons[0]?.code !== 'dependent-copy')) continue;
|
|
2256
|
+
const selected = item.hookCandidates.filter((name) => includedHooks.has(path.join(item.base, name)));
|
|
2257
|
+
if (selected.length) Object.assign(item, { action: 'edit', reasons: [], hookScripts: [...new Set([...item.hookScripts, ...selected])].sort() });
|
|
2258
|
+
}
|
|
2259
|
+
const fresh = new Map(cleanupScopes(plan.inputs, current, includeModified, includedHooks).map((scope) => [scope.id, scope]));
|
|
2056
2260
|
let seq = 0;
|
|
2057
2261
|
const emit = (scope) => { if (events) process.stdout.write(JSON.stringify({ schemaVersion: 1, event: 'scope', seq: ++seq, scope }) + '\n'); };
|
|
2058
2262
|
const scopes = [];
|
|
2059
2263
|
for (const reviewed of plan.scopes) {
|
|
2060
|
-
const pairs = cleanupProven(reviewed, fresh.get(reviewed.id) || { items: [] });
|
|
2264
|
+
const pairs = cleanupCoupledPairs(cleanupProven(reviewed, fresh.get(reviewed.id) || { items: [] }));
|
|
2061
2265
|
const progress = { scopeId: reviewed.id, kind: reviewed.kind, name: reviewed.name, total: pairs.length, state: 'running', delivery: null, error: null };
|
|
2062
2266
|
if (!pairs.length) { scopes.push({ ...progress, state: 'skipped' }); emit(scopes.at(-1)); continue; }
|
|
2063
2267
|
emit(progress);
|
package/bin/skill-history.json
CHANGED
|
@@ -52,6 +52,7 @@
|
|
|
52
52
|
"40f50f3c3c0d030d1efeb6955c0c6f32f518e737d8dd3bec6610ff02badebbdb",
|
|
53
53
|
"430d156eb8c62c7edba3d660050f90b267f928976c3a3688e95d54f00dc34747",
|
|
54
54
|
"450d578ef177a9e8f0b693940ad64b41c1d9719975ba6305f62a7c82ac3a892f",
|
|
55
|
+
"45ef27af9ec27089e2861c13b882521fc6bf073dc6b7a356b326b4a27c5e6e24",
|
|
55
56
|
"4685be6a94ad5587ce53785c14a60cccf9a852d9d23391dba0a5bcd0c64931cf",
|
|
56
57
|
"484db9dd2635de5f5acbcf692e2f329f33c0a483951e27bcd77bcbafbc052e19",
|
|
57
58
|
"4ecb004b24a8adfe41c7416c3fa24e62cf76baa3b276ae1ce5091caef77e57b8",
|
|
@@ -768,6 +769,8 @@
|
|
|
768
769
|
"4.3.10",
|
|
769
770
|
"4.3.11",
|
|
770
771
|
"4.3.12",
|
|
772
|
+
"4.3.13",
|
|
773
|
+
"4.3.14",
|
|
771
774
|
"4.3.2",
|
|
772
775
|
"4.3.3",
|
|
773
776
|
"4.3.4",
|
|
@@ -324,10 +324,12 @@ def allocate(args, *, exclude_receipts=(), warnings=None):
|
|
|
324
324
|
if local:
|
|
325
325
|
receipt["local_only"] = True # Local work needs no remote: nothing is fetched or pushed.
|
|
326
326
|
else:
|
|
327
|
-
|
|
327
|
+
if args.repository is None:
|
|
328
|
+
receipt["repository"] = repository_from_url(gref(receipt, "remote", "get-url", "--all", args.remote))
|
|
328
329
|
policy(receipt, None)
|
|
329
330
|
receipt["remote_url"] = remote_url(receipt)
|
|
330
331
|
require("\n" not in receipt["remote_url"]["push"] and receipt["remote_url"]["push"] == receipt["remote_url"]["fetch"], "split/multiple remote URLs are unsupported")
|
|
332
|
+
repo_policy(receipt, allocating=True)
|
|
331
333
|
target = Path(args.worktree).resolve() # A relative, `..` or symlinked (macOS /tmp) path names its real location.
|
|
332
334
|
require(not target.exists() and all(not target.is_relative_to(p) and not p.is_relative_to(target) for p in map(Path, registrations(receipt))), "linked worktree must be an absent path disjoint from every registered worktree")
|
|
333
335
|
receipt["worktree"] = str(target)
|
|
@@ -541,9 +543,17 @@ def repository_info(repository):
|
|
|
541
543
|
return info
|
|
542
544
|
|
|
543
545
|
|
|
544
|
-
def repo_policy(receipt):
|
|
546
|
+
def repo_policy(receipt, *, allocating=False):
|
|
545
547
|
info = repository_info(receipt["repository"])
|
|
546
|
-
|
|
548
|
+
default = info["defaultBranchRef"]["name"]
|
|
549
|
+
if allocating:
|
|
550
|
+
if receipt["base"] is None:
|
|
551
|
+
gref(receipt, "check-ref-format", "refs/heads/" + default)
|
|
552
|
+
receipt["base"] = default
|
|
553
|
+
require(receipt["branch"] != receipt["base"], "cannot own a base/default branch")
|
|
554
|
+
require(default == receipt["base"] and receipt["branch"] != default,
|
|
555
|
+
f"base {receipt['base']!r} differs from default branch {default!r}; the helper delivers only to the default branch; use a local allocation with --local-base for other bases"
|
|
556
|
+
if allocating else "base/default branch changed; retain resources")
|
|
547
557
|
return info
|
|
548
558
|
|
|
549
559
|
|
|
@@ -1409,7 +1419,8 @@ class CompletionTests(unittest.TestCase):
|
|
|
1409
1419
|
from unittest.mock import patch
|
|
1410
1420
|
target = self.root / "absent" / "new"
|
|
1411
1421
|
exists = Path.exists
|
|
1412
|
-
with patch.
|
|
1422
|
+
with patch.dict(os.environ, self.env), \
|
|
1423
|
+
patch.object(Path, "exists", lambda path: False if path == target or path in target.parents else exists(path)), \
|
|
1413
1424
|
patch.object(shutil, "disk_usage") as usage:
|
|
1414
1425
|
with self.assertRaisesRegex(CompletionError, f"no existing ancestor of {re.escape(str(target))} to measure free space"):
|
|
1415
1426
|
allocate(self.allocation_args("new", target))
|
|
@@ -1677,6 +1688,62 @@ class CompletionTests(unittest.TestCase):
|
|
|
1677
1688
|
self.g("--no-literal-pathspecs", "check-ignore", "-q", "--", record, work=self.task)
|
|
1678
1689
|
self.assertEqual(self.g("status", "--porcelain", "--untracked-files=all", work=self.task), "")
|
|
1679
1690
|
|
|
1691
|
+
def test_allocate_derives_repository(self):
|
|
1692
|
+
result, _ = self.cli("allocate", "--repo", self.work, "--task", "fixture",
|
|
1693
|
+
"--branch", "task/fixture", "--base", "main", "--worktree", self.root / "linked")
|
|
1694
|
+
self.assertEqual(result["status"], "ALLOCATED")
|
|
1695
|
+
self.assertEqual(read_json(Path(result["receipt"]))["repository"], "test/project")
|
|
1696
|
+
|
|
1697
|
+
def test_allocate_derives_default_base(self):
|
|
1698
|
+
self.g("push", "origin", "main:refs/heads/release")
|
|
1699
|
+
self.configure(default_branch="release")
|
|
1700
|
+
for name, flags in (("explicit", ["--repository", "test/project"]), ("derived", [])):
|
|
1701
|
+
with self.subTest(name=name):
|
|
1702
|
+
result, _ = self.cli("allocate", "--repo", self.work, "--task", name,
|
|
1703
|
+
"--branch", "task/" + name, "--worktree", self.root / name, *flags)
|
|
1704
|
+
self.assertEqual(result["status"], "ALLOCATED")
|
|
1705
|
+
receipt = read_json(Path(result["receipt"]))
|
|
1706
|
+
self.assertEqual((receipt["repository"], receipt["base"]), ("test/project", "release"))
|
|
1707
|
+
self.assertEqual(self.g("rev-parse", "HEAD", work=Path(result["worktree"])), receipt["baseline"])
|
|
1708
|
+
|
|
1709
|
+
def test_allocate_rejects_nondefault_base_before_resources(self):
|
|
1710
|
+
self.configure(default_branch="release")
|
|
1711
|
+
self.g("update-ref", "-d", "refs/remotes/origin/main")
|
|
1712
|
+
refs, worktrees = self.g("show-ref"), self.g("worktree", "list", "--porcelain")
|
|
1713
|
+
result, process = self.cli("allocate", "--repo", self.work, "--task", "fixture",
|
|
1714
|
+
"--branch", "task/fixture", "--repository", "test/project", "--base", "main",
|
|
1715
|
+
"--worktree", self.root / "linked", success=False)
|
|
1716
|
+
self.assertNotEqual(process.returncode, 0)
|
|
1717
|
+
self.assertEqual(result["status"], "BLOCKED")
|
|
1718
|
+
for text in ("main", "release", "only to the default branch", "--local-base"):
|
|
1719
|
+
self.assertIn(text, result["reason"])
|
|
1720
|
+
self.assertEqual(self.g("show-ref"), refs)
|
|
1721
|
+
self.assertEqual(self.g("worktree", "list", "--porcelain"), worktrees)
|
|
1722
|
+
self.assertFalse((self.work / ".git/devlyn-completion").exists())
|
|
1723
|
+
self.assertFalse((self.root / "linked").exists())
|
|
1724
|
+
|
|
1725
|
+
def test_allocate_rejects_derived_base_as_task_branch(self):
|
|
1726
|
+
self.configure(default_branch="release")
|
|
1727
|
+
refs = self.g("show-ref")
|
|
1728
|
+
result, process = self.cli("allocate", "--repo", self.work, "--task", "fixture",
|
|
1729
|
+
"--branch", "release", "--worktree", self.root / "linked", success=False)
|
|
1730
|
+
self.assertNotEqual(process.returncode, 0)
|
|
1731
|
+
self.assertEqual(result["status"], "BLOCKED")
|
|
1732
|
+
self.assertIn("cannot own a base/default branch", result["reason"])
|
|
1733
|
+
self.assertEqual(self.g("show-ref"), refs)
|
|
1734
|
+
self.assertEqual(self.g("branch", "--list", "release"), "")
|
|
1735
|
+
self.assertFalse((self.work / ".git/devlyn-completion").exists())
|
|
1736
|
+
self.assertFalse((self.root / "linked").exists())
|
|
1737
|
+
|
|
1738
|
+
def test_allocate_rejects_explicit_repository_mismatch(self):
|
|
1739
|
+
result, process = self.cli("allocate", "--repo", self.work, "--task", "fixture",
|
|
1740
|
+
"--branch", "task/fixture", "--repository", "test/other", "--base", "main",
|
|
1741
|
+
"--worktree", self.root / "linked", success=False)
|
|
1742
|
+
self.assertNotEqual(process.returncode, 0)
|
|
1743
|
+
self.assertEqual(result, {"status": "BLOCKED", "reason": "repository differs from remote"})
|
|
1744
|
+
self.assertFalse((self.work / ".git/devlyn-completion").exists())
|
|
1745
|
+
self.assertFalse((self.root / "linked").exists())
|
|
1746
|
+
|
|
1680
1747
|
def test_allocate_base_with_same_named_tag(self):
|
|
1681
1748
|
self.g("tag", "main")
|
|
1682
1749
|
result = self.allocate()
|
|
@@ -1687,6 +1754,7 @@ class CompletionTests(unittest.TestCase):
|
|
|
1687
1754
|
base = "main\u00a0"
|
|
1688
1755
|
self.g("switch", "-c", base)
|
|
1689
1756
|
self.g("push", "origin", "HEAD:refs/heads/"+base)
|
|
1757
|
+
self.configure(default_branch=base)
|
|
1690
1758
|
result = self.allocate(base=base)
|
|
1691
1759
|
self.assertEqual(result["status"], "ALLOCATED")
|
|
1692
1760
|
self.assertEqual(json.loads(self.receipt.read_text(encoding="utf-8"))["base"], base)
|
|
@@ -1711,6 +1779,7 @@ class CompletionTests(unittest.TestCase):
|
|
|
1711
1779
|
def test_allocate_rejects_short_base_alias(self):
|
|
1712
1780
|
self.g("branch", "heads/main")
|
|
1713
1781
|
self.g("tag", "main")
|
|
1782
|
+
self.configure(default_branch="heads/main")
|
|
1714
1783
|
self.assert_base_identity_rejected("main", "heads/main")
|
|
1715
1784
|
|
|
1716
1785
|
def test_allocate_uses_remote_base_with_dirty_foreign_anchor(self):
|
|
@@ -2295,7 +2364,8 @@ ctypes.CDLL(None).pthread_exit(None)
|
|
|
2295
2364
|
self.merge_pr()
|
|
2296
2365
|
receipt, task = self.receipt, self.task
|
|
2297
2366
|
self.configure(default_branch="changed")
|
|
2298
|
-
|
|
2367
|
+
self.g("push", "origin", "main:refs/heads/changed")
|
|
2368
|
+
result = self.allocate("next", base="changed")
|
|
2299
2369
|
self.assertEqual([(row["receipt"], row["status"]) for row in result["reconciled"]], [(str(receipt), "RETAINED")])
|
|
2300
2370
|
self.assertIn("base/default branch changed", result["reconciled"][0]["reason"])
|
|
2301
2371
|
self.assertTrue(task.exists())
|
|
@@ -2398,12 +2468,13 @@ ctypes.CDLL(None).pthread_exit(None)
|
|
|
2398
2468
|
self.assertEqual(self.g("branch", "--list", "task/fixture"), "")
|
|
2399
2469
|
self.assertEqual(self.g("ls-remote", "origin", "refs/heads/task/fixture"), "")
|
|
2400
2470
|
self.configure(default_branch="changed")
|
|
2471
|
+
self.g("push", "origin", "main:refs/heads/changed")
|
|
2401
2472
|
server = json.loads(self.data.read_text())
|
|
2402
|
-
result = self.allocate("retry")
|
|
2473
|
+
result = self.allocate("retry", base="changed")
|
|
2403
2474
|
self.assertEqual([(row["receipt"], row["status"]) for row in result["reconciled"]], [(str(receipt), "COMPLETE")])
|
|
2404
2475
|
self.assertFalse(any(scratch.iterdir()))
|
|
2405
2476
|
self.assertEqual(json.loads(self.data.read_text()), server)
|
|
2406
|
-
self.assertEqual(self.allocate("later")["reconciled"], [])
|
|
2477
|
+
self.assertEqual(self.allocate("later", base="changed")["reconciled"], [])
|
|
2407
2478
|
|
|
2408
2479
|
def test_reconcile_retries_unattempted_scratch_cleanup(self):
|
|
2409
2480
|
scratch = Path(self.allocate()["scratch"])
|
|
@@ -3158,14 +3229,10 @@ ctypes.CDLL(None).pthread_exit(None)
|
|
|
3158
3229
|
self.assertFalse((self.root / "next").exists())
|
|
3159
3230
|
|
|
3160
3231
|
def test_local_allocation_needs_no_repository(self):
|
|
3161
|
-
"""
|
|
3162
|
-
local allocation, here beside an origin that is not GitHub, is ALLOCATED and its local-only completion returns
|
|
3163
|
-
LOCAL_ONLY with nothing pushed. Before: argparse required --repository, so both failed as an argparse error."""
|
|
3232
|
+
"""A local allocation beside a non-GitHub origin needs no repository and completes without publication."""
|
|
3164
3233
|
args = ["allocate", "--repo", self.work, "--base", "main"]
|
|
3165
|
-
refused, _ = self.cli(*args, "--task", "next", "--branch", "task/next", "--worktree", self.root / "next", success=False)
|
|
3166
|
-
self.assertIn("--repository", refused["reason"])
|
|
3167
|
-
self.assertEqual((self.g("branch", "--list", "task/next"), (self.root / "next").exists()), ("", False))
|
|
3168
3234
|
self.g("remote", "set-url", "origin", "https://gitlab.com/team/project.git")
|
|
3235
|
+
(self.bin / "gh").write_text("#!/bin/sh\nexit 1\n", encoding="utf-8")
|
|
3169
3236
|
result, _ = self.cli(*args, "--task", "fixture", "--branch", "task/fixture", "--worktree", self.root / "linked",
|
|
3170
3237
|
"--local-base", self.g("rev-parse", "HEAD"))
|
|
3171
3238
|
self.assertEqual(result["status"], "ALLOCATED")
|
|
@@ -3175,13 +3242,8 @@ ctypes.CDLL(None).pthread_exit(None)
|
|
|
3175
3242
|
self.assertEqual(json.loads(self.data.read_text(encoding="utf-8")).get("pushs", 0), 0)
|
|
3176
3243
|
|
|
3177
3244
|
def test_local_allocation_needs_no_base(self):
|
|
3178
|
-
"""
|
|
3179
|
-
runs, is ALLOCATED and completes LOCAL_ONLY, while a remote allocation without --base is refused naming it, with
|
|
3180
|
-
nothing created."""
|
|
3245
|
+
"""A local allocation with --local-base and no --base is ALLOCATED and completes LOCAL_ONLY."""
|
|
3181
3246
|
args = ["allocate", "--repo", self.work, "--task", "fixture", "--branch", "task/fixture", "--worktree", self.root / "linked"]
|
|
3182
|
-
refused, _ = self.cli(*args, "--repository", "test/project", success=False)
|
|
3183
|
-
self.assertIn("--base", refused["reason"])
|
|
3184
|
-
self.assertEqual((self.g("branch", "--list", "task/fixture"), (self.root / "linked").exists()), ("", False))
|
|
3185
3247
|
result, _ = self.cli(*args, "--local-base", self.g("rev-parse", "HEAD"))
|
|
3186
3248
|
self.assertEqual(result["status"], "ALLOCATED")
|
|
3187
3249
|
self.receipt, self.task = Path(result["receipt"]), Path(result["worktree"])
|
|
@@ -55,9 +55,11 @@ not write to the shared checkout.
|
|
|
55
55
|
python3 "$DEVLYN_SHARED_DIR/task-complete.py" allocate --repo . \
|
|
56
56
|
--task '<task identity>' --branch '<absent task branch>' \
|
|
57
57
|
--worktree '<repo parent>/.devlyn-worktrees/<repo name>/<task directory>' \
|
|
58
|
-
--
|
|
58
|
+
--remote origin
|
|
59
59
|
```
|
|
60
60
|
|
|
61
|
+
The repository comes from the remote's GitHub URL and the base from the repository's default branch; passing either flag checks it instead.
|
|
62
|
+
A non-default base is refused at allocation.
|
|
61
63
|
Every delivery allocation owns a linked worktree; `--worktree` is required and
|
|
62
64
|
must name an absent path.
|
|
63
65
|
Cleanup removes empty parents through the exact per-repo root shown above or its legacy `.devlyn.noindex`/`.devlyn` root, keeping the shared `.devlyn-worktrees` container. Allocation also sweeps empty legacy roots for this repo.
|