feat(env): per-workspace PATH preset injected at spawn (T-511)
Implements D-106. The T-439 login-shell probe is a global heuristic with a known hole — login-but-non-interactive shells skip ~/.bashrc, so interactive-only additions (brew shellenv) never reach the agent's Bash tool or terminal panes on a desktop launch. The preset is the explicit per-repo layer on top: user-scope storage keyed by repo identity (a linked worktree resolves through its gitdir pointer to the main repo, so worktrees share the preset), prepended at spawn via the PaneRegistry pathForSpawn hook and agentEnvDelta prependDirs — which now exports PATH even when clide is already resolvable, closing the gap where the hosted session inherited the sparse GUI PATH untouched. CLI half: `clide env path list|set|add|remove|clear|capture` over an injected Flutter-free store port; capture diffs the login-shell PATH against the process PATH to suggest the dirs a desktop launch dropped. Binary resolution (toolchain, supporter pins, bundled pql/git) stays preset-blind per the D-92/T-98 fence. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
Vendored
+139
@@ -0,0 +1,139 @@
|
||||
/// Tests for the per-workspace PATH preset (D-106, T-511): the pure prepend /
|
||||
/// capture-diff helpers, the worktree-aware preset root, and the settings-key
|
||||
/// derivation. Runs under plain `dart test` (Flutter-free).
|
||||
library;
|
||||
|
||||
import 'dart:io';
|
||||
|
||||
import 'package:clide/src/env/path_preset.dart';
|
||||
import 'package:test/test.dart';
|
||||
|
||||
void main() {
|
||||
group('applyPathPreset', () {
|
||||
test('prepends preset dirs ahead of the base', () {
|
||||
expect(applyPathPreset('/usr/bin:/bin', ['/opt/go/bin', '/x']), '/opt/go/bin:/x:/usr/bin:/bin');
|
||||
});
|
||||
|
||||
test('empty preset returns the base unchanged', () {
|
||||
expect(applyPathPreset('/usr/bin', const []), '/usr/bin');
|
||||
expect(applyPathPreset('/usr/bin', ['', ' ']), '/usr/bin');
|
||||
});
|
||||
|
||||
test('empty base returns just the preset', () {
|
||||
expect(applyPathPreset('', ['/a', '/b']), '/a:/b');
|
||||
});
|
||||
|
||||
test('de-duplicates within the preset, order kept', () {
|
||||
expect(applyPathPreset('/bin', ['/a', '/b', '/a']), '/a:/b:/bin');
|
||||
});
|
||||
|
||||
test('drops base entries the preset already names (preset wins)', () {
|
||||
expect(applyPathPreset('/usr/bin:/a:/bin', ['/a']), '/a:/usr/bin:/bin');
|
||||
});
|
||||
|
||||
test('honours a custom separator', () {
|
||||
expect(applyPathPreset(r'C:\bin', [r'C:\go'], sep: ';'), r'C:\go;C:\bin');
|
||||
});
|
||||
});
|
||||
|
||||
group('missingLoginShellDirs', () {
|
||||
test('null or empty login PATH → nothing to suggest', () {
|
||||
expect(missingLoginShellDirs(loginPath: null, processPath: '/bin'), isEmpty);
|
||||
expect(missingLoginShellDirs(loginPath: '', processPath: '/bin'), isEmpty);
|
||||
});
|
||||
|
||||
test('suggests login entries the process PATH lacks, order kept', () {
|
||||
expect(missingLoginShellDirs(loginPath: '/home/linuxbrew/.linuxbrew/bin:/usr/bin:/opt/go/bin:/bin', processPath: '/usr/bin:/bin'), [
|
||||
'/home/linuxbrew/.linuxbrew/bin',
|
||||
'/opt/go/bin',
|
||||
]);
|
||||
});
|
||||
|
||||
test('de-duplicates and skips empty segments', () {
|
||||
expect(missingLoginShellDirs(loginPath: '/a::/a:/b', processPath: '/bin'), ['/a', '/b']);
|
||||
});
|
||||
});
|
||||
|
||||
group('presetRootFor (worktree resolution)', () {
|
||||
test('a normal repo (.git directory) keys off itself', () {
|
||||
expect(presetRootFor('/repo', isFile: (_) => false, readFile: (_) => fail('not read')), '/repo');
|
||||
});
|
||||
|
||||
test('trailing separators are stripped', () {
|
||||
expect(presetRootFor('/repo//', isFile: (_) => false, readFile: (_) => null), '/repo');
|
||||
});
|
||||
|
||||
test('an in-repo .worktrees worktree resolves to the main repo root (absolute gitdir)', () {
|
||||
expect(
|
||||
presetRootFor('/repo/.worktrees/fix', isFile: (p) => p == '/repo/.worktrees/fix/.git', readFile: (p) => 'gitdir: /repo/.git/worktrees/fix\n'),
|
||||
'/repo',
|
||||
);
|
||||
});
|
||||
|
||||
test('a relative gitdir pointer resolves against the worktree root', () {
|
||||
expect(
|
||||
presetRootFor('/repo/.worktrees/fix', isFile: (p) => p == '/repo/.worktrees/fix/.git', readFile: (p) => 'gitdir: ../../.git/worktrees/fix'),
|
||||
'/repo',
|
||||
);
|
||||
});
|
||||
|
||||
test('a worktree outside the repo still resolves to the main root', () {
|
||||
expect(presetRootFor('/tmp/wt', isFile: (p) => p == '/tmp/wt/.git', readFile: (_) => 'gitdir: /srv/repos/main/.git/worktrees/wt'), '/srv/repos/main');
|
||||
});
|
||||
|
||||
test('a gitdir pointer without the worktrees marker (submodule-style) keys off itself', () {
|
||||
expect(presetRootFor('/repo/sub', isFile: (p) => p == '/repo/sub/.git', readFile: (_) => 'gitdir: /repo/.git/modules/sub'), '/repo/sub');
|
||||
});
|
||||
|
||||
test('malformed or unreadable pointer files key off the workspace itself', () {
|
||||
expect(presetRootFor('/w', isFile: (_) => true, readFile: (_) => 'not a pointer'), '/w');
|
||||
expect(presetRootFor('/w', isFile: (_) => true, readFile: (_) => null), '/w');
|
||||
});
|
||||
|
||||
test('backslashed gitdir (Windows-written pointer) still matches', () {
|
||||
expect(presetRootFor('/repo/.worktrees/x', isFile: (p) => p == '/repo/.worktrees/x/.git', readFile: (_) => r'gitdir: /repo/.git\worktrees\x'), '/repo');
|
||||
});
|
||||
|
||||
test('resolves a REAL worktree layout on disk (no injected probes)', () {
|
||||
final tmp = Directory.systemTemp.createTempSync('preset-root');
|
||||
addTearDown(() => tmp.deleteSync(recursive: true));
|
||||
final repo = Directory('${tmp.path}/repo')..createSync();
|
||||
Directory('${repo.path}/.git/worktrees/fix').createSync(recursive: true);
|
||||
final wt = Directory('${repo.path}/.worktrees/fix')..createSync(recursive: true);
|
||||
File('${wt.path}/.git').writeAsStringSync('gitdir: ${repo.path}/.git/worktrees/fix\n');
|
||||
expect(presetRootFor(wt.path), repo.path);
|
||||
expect(presetRootFor(repo.path), repo.path);
|
||||
});
|
||||
});
|
||||
|
||||
group('pathPresetKey', () {
|
||||
test('is an app-layer key under the preset prefix', () {
|
||||
final key = pathPresetKey('/repo', isFile: (_) => false, readFile: (_) => null);
|
||||
expect(key, startsWith(pathPresetKeyPrefix));
|
||||
expect(key.length, pathPresetKeyPrefix.length + 16, reason: '16-hex FNV-1a suffix');
|
||||
});
|
||||
|
||||
test('a worktree and its main repo share one key; trailing slash is unified', () {
|
||||
final main = pathPresetKey('/repo', isFile: (_) => false, readFile: (_) => null);
|
||||
final slash = pathPresetKey('/repo/', isFile: (_) => false, readFile: (_) => null);
|
||||
final wt = pathPresetKey('/repo/.worktrees/fix', isFile: (p) => p == '/repo/.worktrees/fix/.git', readFile: (_) => 'gitdir: /repo/.git/worktrees/fix');
|
||||
expect(slash, main);
|
||||
expect(wt, main);
|
||||
expect(pathPresetKey('/other', isFile: (_) => false, readFile: (_) => null), isNot(main));
|
||||
});
|
||||
});
|
||||
|
||||
group('presetDirsFrom', () {
|
||||
List<String> read(Object? stored) => presetDirsFrom((_) => stored, '/repo', isFile: (_) => false, readFile: (_) => null);
|
||||
|
||||
test('reads a stored list of dirs', () {
|
||||
expect(read(['/a', '/b']), ['/a', '/b']);
|
||||
});
|
||||
|
||||
test('tolerates malformed values', () {
|
||||
expect(read(null), isEmpty);
|
||||
expect(read('nonsense'), isEmpty);
|
||||
expect(read([1, '', ' ', '/ok', true]), ['/ok']);
|
||||
});
|
||||
});
|
||||
}
|
||||
Reference in New Issue
Block a user