Files
gstack/test/gstack-slug-project-marker.test.ts
T
Garry Tanandcapy-ai[bot] 482ffd1440 v1.92.14.0 feat: a .gstack-project file makes a nested repo or umbrella folder its own project (#2649, #1923) (#3777)
* feat: a .gstack-project marker makes a nested repo or umbrella folder its own project (#2649, #1923)

Co-authored-by: capy-ai[bot] <230910855+capy-ai[bot]@users.noreply.github.com>

* test: marker tests ignore an ambient GSTACK_STATE_ROOT

Co-authored-by: capy-ai[bot] <230910855+capy-ai[bot]@users.noreply.github.com>

* fix: pre-landing review fixes (per-repo records ignore every marker, worktree binding in remote-slug and under a git HOME)

Co-authored-by: capy-ai[bot] <230910855+capy-ai[bot]@users.noreply.github.com>

* fix(slug): a linked worktree finds its main checkout's marker on its first run

The worktree binding was written only when the main checkout resolved, so a
worktree outside an umbrella resolved first kept its old bucket and later
switched. Walk up from the main checkout on a miss instead (under its own
$HOME rule) and drop the binding file.

Co-authored-by: capy-ai[bot] <230910855+capy-ai[bot]@users.noreply.github.com>

* docs/test: pregate user policy names REPO_SLUG; pin greptile consent to REPO_SLUG

Co-authored-by: capy-ai[bot] <230910855+capy-ai[bot]@users.noreply.github.com>

* fix(slug): no-clobber checkpoint recovery, unambiguous marker cache keys, ignore unusable folder names; remote-slug resolves once

Co-authored-by: capy-ai[bot] <230910855+capy-ai[bot]@users.noreply.github.com>

* fix(slug): remote-slug reads a quoted marker path; repo mode stays per repository

Co-authored-by: capy-ai[bot] <230910855+capy-ai[bot]@users.noreply.github.com>

* fix(slug): the review log and /ship spend-cap pointer stay with the repository under a marker; recovered checkpoints keep their repository; a linked worktree's own marker stands for its main checkout

Co-authored-by: capy-ai[bot] <230910855+capy-ai[bot]@users.noreply.github.com>

* docs(captain): approvals live under the repository's own slug

Co-authored-by: capy-ai[bot] <230910855+capy-ai[bot]@users.noreply.github.com>

---------

Co-authored-by: capy-ai[bot] <230910855+capy-ai[bot]@users.noreply.github.com>
2026-11-01 05:57:18 +00:00

444 lines
22 KiB
TypeScript

/**
* .gstack-project marker (#2649, #1923): an opt-in file that makes its folder a
* project of its own. Pinned for bin/gstack-slug, its TS twin in
* lib/bin-context.ts, and browse/bin/remote-slug. The default (outermost
* remote-bearing repo wins) is pinned unchanged in test/gstack-slug-parity.test.ts.
*/
import { describe, test, expect, beforeEach, afterEach } from 'bun:test';
import { spawnSync } from 'child_process';
import * as fs from 'fs';
import * as os from 'os';
import * as path from 'path';
import { projectMarkerRoot, readVersionedSlugCache, slugFromEnvironment } from '../lib/bin-context';
import { defaultUserConfigPath } from '../lib/pregate/context';
import { reviewPaths } from '../lib/ship-budget';
const ROOT = path.resolve(import.meta.dir, '..');
const SLUG = path.join(ROOT, 'bin', 'gstack-slug');
const REMOTE_SLUG = path.join(ROOT, 'browse', 'bin', 'remote-slug');
let home: string;
let state: string;
let saved: Record<string, string | undefined>;
beforeEach(() => {
home = fs.realpathSync(fs.mkdtempSync(path.join(os.tmpdir(), 'gstack-marker-')));
state = path.join(home, '.gstack');
saved = { HOME: process.env.HOME, GSTACK_HOME: process.env.GSTACK_HOME, GSTACK_PROJECT_SLUG: process.env.GSTACK_PROJECT_SLUG };
process.env.HOME = home;
process.env.GSTACK_HOME = state;
delete process.env.GSTACK_PROJECT_SLUG;
});
afterEach(() => {
for (const [k, v] of Object.entries(saved)) {
if (v === undefined) delete process.env[k];
else process.env[k] = v;
}
fs.rmSync(home, { recursive: true, force: true });
});
function env(extra: Record<string, string> = {}): Record<string, string | undefined> {
const { GSTACK_PROJECT_SLUG: _p, GSTACK_STATE_ROOT: _r, GSTACK_STATE_DIR: _d, CLAUDE_PLUGIN_DATA: _c, ...ambient } = process.env;
return { ...ambient, HOME: home, GSTACK_HOME: state, ...extra };
}
function run(script: string, cwd: string, args: string[] = [], extra: Record<string, string> = {}) {
return spawnSync('bash', [script, ...args], { cwd, env: env(extra), encoding: 'utf8', timeout: 15_000 });
}
const slug = (cwd: string, extra: Record<string, string> = {}) => run(SLUG, cwd, ['--get', 'SLUG'], extra).stdout.trim();
function identity(cwd: string): string {
const r = run(SLUG, cwd, ['--identity']);
expect(r.status).toBe(0);
return r.stdout;
}
function git(args: string[], cwd?: string): void {
const r = spawnSync('git', args, { cwd, encoding: 'utf8', timeout: 15_000, env: env({ GIT_AUTHOR_NAME: 't', GIT_AUTHOR_EMAIL: 't@t', GIT_COMMITTER_NAME: 't', GIT_COMMITTER_EMAIL: 't@t' }) });
if (r.status !== 0) throw new Error(`git ${args.join(' ')}: ${r.stderr}`);
}
function repo(dir: string, origin?: string): string {
fs.mkdirSync(dir, { recursive: true });
git(['init', '-q', '-b', 'main', dir]);
if (origin) git(['-C', dir, 'remote', 'add', 'origin', origin]);
return dir;
}
const mark = (dir: string) => fs.writeFileSync(path.join(dir, '.gstack-project'), '');
const cacheFiles = () => {
const dir = path.join(state, 'slug-cache');
return Object.fromEntries(fs.readdirSync(dir).filter((f) => fs.statSync(path.join(dir, f)).isFile()).map((f) => [f, fs.readFileSync(path.join(dir, f), 'utf8')]));
};
/** ws (me/workspace) holding the independent repo ws/proj (org/proj), as in #2649. */
function workspace() {
const ws = repo(path.join(home, 'ws'), 'git@github.com:me/workspace.git');
const proj = repo(path.join(ws, 'proj'), 'git@github.com:org/proj.git');
fs.mkdirSync(path.join(proj, 'app'));
return { ws, proj, app: path.join(proj, 'app') };
}
/** A plain (non-git) umbrella over two repos, as in #1923. */
function umbrella() {
const top = path.join(home, 'MyProduct');
const backend = repo(path.join(top, 'backend'), 'git@github.com:org/backend.git');
const frontend = repo(path.join(top, 'frontend'), 'git@github.com:org/frontend.git');
return { top, backend, frontend };
}
describe('gstack-slug: .gstack-project marker', () => {
test('a marker at a nested repo\'s top folder gives it (and its subfolders) its own slug; the outer repo is unchanged', () => {
const { ws, proj, app } = workspace();
const first = run(SLUG, proj, ['--get', 'SLUG']);
expect(first.stdout.trim()).toBe('me-workspace');
expect(first.stderr).toContain(`touch '${proj}/.gstack-project'`);
expect(run(SLUG, proj, ['--get', 'SLUG']).stderr).toBe('');
fs.mkdirSync(path.join(state, 'projects', 'me-workspace', 'checkpoints'), { recursive: true });
fs.writeFileSync(path.join(state, 'projects', 'me-workspace', 'checkpoints', '20261001-120000-x.md'), '## Working on: x\n');
mark(proj);
const moved = run(SLUG, proj, ['--get', 'SLUG']);
expect(moved.stdout.trim()).toBe('org-proj');
expect(moved.stderr).toContain('--adopt-legacy --from me-workspace --under-root');
expect(run(SLUG, proj, ['--get', 'SLUG']).stderr).toBe('');
expect(run(SLUG, app, ['--get', 'PROJECT_MARKER']).stdout.trim()).toBe(path.join(proj, '.gstack-project'));
expect(run(SLUG, app, ['--get', 'REPO_SLUG']).stdout.trim()).toBe('me-workspace');
expect(slug(app)).toBe('org-proj');
expect(slug(ws)).toBe('me-workspace');
expect(identity(app)).toContain(`PROJECT_MARKER=${path.join(proj, '.gstack-project')}`);
expect(identity(app)).toContain(`PROJECT_ROOT=${proj}`);
});
test('the marker beats a sticky cache entry, and removing it restores every repo entry byte for byte', () => {
const { proj } = workspace();
expect(slug(proj)).toBe('me-workspace');
for (const p of [path.join(proj, '.git'), proj]) fs.writeFileSync(path.join(state, 'slug-cache', p.replace(/\//g, '_')), 'v2:sticky-name');
expect(slug(proj)).toBe('sticky-name');
const before = cacheFiles();
mark(proj);
expect(slug(proj)).toBe('org-proj');
expect(cacheFiles()).toEqual(before);
fs.rmSync(path.join(proj, '.gstack-project'));
expect(slug(proj)).toBe('sticky-name');
expect(cacheFiles()).toEqual(before);
});
test('an umbrella marker folds child repos into one project; a child\'s own marker wins', () => {
const { top, backend, frontend } = umbrella();
expect(slug(backend)).toBe('org-backend');
mark(top);
expect(slug(top)).toBe('MyProduct');
expect(slug(backend)).toBe('MyProduct');
expect(slug(frontend)).toBe('MyProduct');
mark(backend);
expect(slug(backend)).toBe('org-backend');
expect(slug(frontend)).toBe('MyProduct');
});
test('GSTACK_PROJECT_SLUG still beats a marker', () => {
const { proj } = workspace();
mark(proj);
expect(slug(proj, { GSTACK_PROJECT_SLUG: 'pinned' })).toBe('pinned');
});
test('a marker in a subfolder of a repo is ignored, with the reason in --identity', () => {
const { ws } = workspace();
const sub = path.join(ws, 'docs');
fs.mkdirSync(sub);
mark(sub);
expect(slug(sub)).toBe('me-workspace');
expect(identity(sub)).toMatch(/MARKER_IGNORED=.*inside\\ the\\ git\\ work\\ tree/);
});
test('a linked, directory or dangling marker is ignored', () => {
const { top, backend } = umbrella();
fs.symlinkSync(path.join(home, 'nowhere'), path.join(top, '.gstack-project'));
expect(slug(backend)).toBe('org-backend');
fs.rmSync(path.join(top, '.gstack-project'));
fs.mkdirSync(path.join(top, '.gstack-project'));
expect(slug(backend)).toBe('org-backend');
expect(identity(backend)).toMatch(/MARKER_IGNORED=.*a\\ directory/);
});
test('a marker at $HOME is ignored', () => {
const { backend } = umbrella();
mark(home);
expect(slug(backend)).toBe('org-backend');
expect(identity(backend)).toMatch(/MARKER_IGNORED=.*at\\ or\\ above/);
});
test('a hostile origin at the marked folder falls back to its basename', () => {
const proj = repo(path.join(home, 'outer', 'evil'), '..');
mark(proj);
expect(slug(proj)).toBe('evil');
});
test('a linked worktree outside the umbrella follows it while the marker exists', () => {
const { top, backend } = umbrella();
git(['-C', backend, 'commit', '-q', '--allow-empty', '-m', 'init']);
mark(top);
expect(slug(backend)).toBe('MyProduct');
const wt = path.join(home, 'elsewhere', 'wt');
git(['-C', backend, 'worktree', 'add', '-q', wt]);
expect(slug(wt)).toBe('MyProduct');
fs.rmSync(path.join(top, '.gstack-project'));
expect(slug(wt)).toBe('org-backend');
});
test('a linked worktree outside the umbrella follows it before the main checkout has ever resolved', () => {
const { top, backend } = umbrella();
git(['-C', backend, 'commit', '-q', '--allow-empty', '-m', 'init']);
mark(top);
const wt = path.join(home, 'elsewhere', 'wt');
git(['-C', backend, 'worktree', 'add', '-q', wt]);
expect(projectMarkerRoot(wt)).toBe(top);
expect(slug(wt)).toBe('MyProduct');
expect(slugFromEnvironment(state, wt)).toBe('MyProduct');
expect(run(REMOTE_SLUG, wt).stdout.trim()).toBe('MyProduct');
expect(slug(backend)).toBe('MyProduct');
expect(slug(wt)).toBe('MyProduct');
});
test('a worktree outside $HOME ignores a $HOME marker above its main checkout', () => {
const backend = repo(path.join(home, 'code', 'backend'), 'git@github.com:org/backend.git');
git(['-C', backend, 'commit', '-q', '--allow-empty', '-m', 'init']);
mark(home);
const outside = fs.realpathSync(fs.mkdtempSync(path.join(os.tmpdir(), 'gstack-marker-out-')));
try {
const wt = path.join(outside, 'wt');
git(['-C', backend, 'worktree', 'add', '-q', wt]);
expect(slug(wt)).toBe('org-backend');
expect(projectMarkerRoot(wt)).toBe('');
expect(slugFromEnvironment(state, wt)).toBe('org-backend');
} finally {
fs.rmSync(outside, { recursive: true, force: true });
}
});
test('a worktree outside the umbrella follows it even when $HOME is itself a git repository', () => {
git(['init', '-q', home]);
const { top, backend } = umbrella();
git(['-C', backend, 'commit', '-q', '--allow-empty', '-m', 'init']);
mark(top);
expect(slug(backend)).toBe('MyProduct');
const outside = fs.realpathSync(fs.mkdtempSync(path.join(os.tmpdir(), 'gstack-marker-out-')));
try {
const wt = path.join(outside, 'wt');
git(['-C', backend, 'worktree', 'add', '-q', wt]);
expect(slug(wt)).toBe('MyProduct');
expect(slugFromEnvironment(state, wt)).toBe('MyProduct');
expect(run(REMOTE_SLUG, wt).stdout.trim()).toBe('MyProduct');
} finally {
fs.rmSync(outside, { recursive: true, force: true });
}
});
test('remote-slug agrees with gstack-slug under a counted marker and keeps its own answer otherwise', () => {
const { top, backend } = umbrella();
mark(top);
expect(run(REMOTE_SLUG, backend).stdout.trim()).toBe('MyProduct');
const { ws, proj } = workspace();
mark(path.join(ws));
fs.rmSync(path.join(ws, '.gstack-project'));
expect(run(REMOTE_SLUG, proj).stdout.trim()).toBe('org-proj');
mark(proj);
expect(run(REMOTE_SLUG, proj).stdout.trim()).toBe(slug(proj));
});
test('--adopt-legacy --under-root previews, then copies and restamps only checkpoints saved inside the marked folder', () => {
const { ws, proj, app } = workspace();
const old = path.join(state, 'projects', 'me-workspace', 'checkpoints');
fs.mkdirSync(old, { recursive: true });
const ck = (name: string, wt: string) => fs.writeFileSync(path.join(old, name), `---\nremote: github.com/me/workspace\nproject_root: ${ws}\nworktree: ${wt}\nhead: abc123\n---\n## Working on: x\n`);
ck('20261001-120000-inner.md', proj);
ck('20261002-120000-sibling.md', path.join(ws, 'other'));
fs.writeFileSync(path.join(old, '20260901-120000-old.md'), '## Working on: unstamped\n');
mark(proj);
const preview = run(SLUG, app, ['--adopt-legacy', '--from', 'me-workspace', '--under-root']);
expect(preview.status).toBe(0);
expect(preview.stdout).toContain('WOULD_COPY');
expect(preview.stdout).toContain('1 checkpoint(s) predate identity stamps');
expect(fs.existsSync(path.join(state, 'projects', 'org-proj', 'checkpoints'))).toBe(false);
const apply = run(SLUG, app, ['--adopt-legacy', '--from', 'me-workspace', '--under-root', '--apply']);
expect(apply.status).toBe(0);
const copied = fs.readdirSync(path.join(state, 'projects', 'org-proj', 'checkpoints'));
expect(copied).toEqual(['20261001-120000-inner.md']);
const text = fs.readFileSync(path.join(state, 'projects', 'org-proj', 'checkpoints', copied[0]), 'utf8');
expect(text).toContain('head: abc123');
expect(text).toContain(`project_root: ${proj}`);
const cls = spawnSync('bash', [SLUG, '--classify-checkpoints'], { cwd: app, env: env(), input: path.join(state, 'projects', 'org-proj', 'checkpoints', copied[0]) + '\n', encoding: 'utf8', timeout: 15_000 });
expect(cls.stdout.split('\t')[0]).toBe('match');
expect(fs.readdirSync(old).length).toBe(3);
});
test('--adopt-legacy --under-root never replaces what already sits at the destination name', () => {
const { ws, proj, app } = workspace();
const old = path.join(state, 'projects', 'me-workspace', 'checkpoints');
fs.mkdirSync(old, { recursive: true });
fs.writeFileSync(path.join(old, '20261001-120000-inner.md'), `---\nproject_root: ${ws}\nworktree: ${proj}\n---\n## Working on: x\n`);
mark(proj);
const dest = path.join(state, 'projects', 'org-proj', 'checkpoints');
fs.mkdirSync(dest, { recursive: true });
fs.symlinkSync(path.join(home, 'saved-elsewhere.md'), path.join(dest, '20261001-120000-inner.md'));
const apply = run(SLUG, app, ['--adopt-legacy', '--from', 'me-workspace', '--under-root', '--apply']);
expect(apply.stdout).not.toContain('COPIED');
expect(fs.lstatSync(path.join(dest, '20261001-120000-inner.md')).isSymbolicLink()).toBe(true);
expect(fs.readdirSync(dest)).toEqual(['20261001-120000-inner.md']);
});
test('a marked folder whose name has no usable letters or digits is ignored, the same in bash and TS', () => {
const top = path.join(home, '项目');
const backend = repo(path.join(top, 'backend'), 'git@github.com:org/backend.git');
fs.mkdirSync(path.join(backend, 'src'));
mark(top);
expect(slug(path.join(backend, 'src'))).toBe('org-backend');
expect(slugFromEnvironment(state, path.join(backend, 'src'))).toBe('org-backend');
expect(projectMarkerRoot(top)).toBe('');
expect(identity(top)).toMatch(/MARKER_IGNORED=.*folder\\ name/);
expect(run(REMOTE_SLUG, path.join(backend, 'src')).stdout.trim()).toBe('org-backend');
});
test('remote-slug follows a marker under a non-ASCII parent in the C locale', () => {
const top = path.join(home, '文档', 'proj');
const backend = repo(path.join(top, 'backend'), 'git@github.com:org/backend.git');
mark(top);
expect(slug(backend, { LC_ALL: 'C' })).toBe('proj');
expect(run(REMOTE_SLUG, backend, [], { LC_ALL: 'C' }).stdout.trim()).toBe('proj');
});
test('repo mode stays with each repository under an umbrella', () => {
const { top, backend } = umbrella();
for (let i = 0; i < 5; i++) git(['-C', backend, 'commit', '-q', '--allow-empty', '-m', `c${i}`]);
mark(top);
expect(slug(backend)).toBe('MyProduct');
const r = run(path.join(ROOT, 'bin', 'gstack-repo-mode'), backend);
expect(r.stdout).toContain('REPO_MODE=solo');
expect(fs.existsSync(path.join(state, 'projects', 'MyProduct', 'repo-mode.json'))).toBe(false);
expect(fs.existsSync(path.join(state, 'projects', 'org-backend', 'repo-mode.json'))).toBe(true);
});
test('the ship-budget pointer and the review log stay with the repository when a marker appears mid-run', () => {
const { proj } = workspace();
git(['-C', proj, 'commit', '-q', '--allow-empty', '-m', 'init']);
const before = reviewPaths(proj, env());
expect(before.marker).toContain(path.join('projects', 'me-workspace') + path.sep);
mark(proj);
expect(slug(proj)).toBe('org-proj');
expect(reviewPaths(proj, env())).toEqual(before);
const branch = run(SLUG, proj, ['--get', 'BRANCH']).stdout.trim();
fs.mkdirSync(path.join(state, 'projects', 'me-workspace'), { recursive: true });
fs.writeFileSync(path.join(state, 'projects', 'me-workspace', `${branch}-reviews.jsonl`), '{"skill":"review","status":"clean"}\n');
expect(run(path.join(ROOT, 'bin', 'gstack-review-read'), proj).stdout).toContain('"skill":"review"');
});
test('sibling repos under an umbrella keep separate ship-budget pointers', () => {
const { top, backend, frontend } = umbrella();
mark(top);
expect(reviewPaths(backend, env()).marker).not.toBe(reviewPaths(frontend, env()).marker);
expect(reviewPaths(backend, env()).reviewLog).toContain(path.join('projects', 'org-backend') + path.sep);
});
test('recovered checkpoints keep the repository they were saved in', () => {
const { top, backend } = umbrella();
const old = path.join(state, 'projects', 'org-backend', 'checkpoints');
fs.mkdirSync(old, { recursive: true });
fs.writeFileSync(path.join(old, '20261001-120000-x.md'), `---\nremote: github.com/org/backend\nproject_root: ${backend}\nhead: abc123\n---\n## Working on: x\n`);
mark(top);
const apply = run(SLUG, backend, ['--adopt-legacy', '--from', 'org-backend', '--under-root', '--apply']);
expect(apply.stdout).toContain('COPIED');
const text = fs.readFileSync(path.join(state, 'projects', 'MyProduct', 'checkpoints', '20261001-120000-x.md'), 'utf8');
expect(text).toContain(`worktree: ${backend}`);
expect(text).toContain(`project_root: ${top}`);
});
test('a committed marker in a repo with no remote gives its linked worktrees the same slug', () => {
const solo = repo(path.join(home, 'solo'));
mark(solo);
git(['-C', solo, 'add', '.gstack-project']);
git(['-C', solo, 'commit', '-q', '-m', 'marker']);
const wt = path.join(home, 'elsewhere', 'feature-wt');
git(['-C', solo, 'worktree', 'add', '-q', wt]);
expect(slug(solo)).toBe('solo');
expect(slug(wt)).toBe('solo');
expect(slugFromEnvironment(state, wt)).toBe('solo');
});
test('marked folders whose paths differ only by / and _ keep separate slugs', () => {
const a = path.join(home, 'team_app');
const b = path.join(home, 'team', 'app');
for (const d of [a, b]) { fs.mkdirSync(d, { recursive: true }); mark(d); }
expect(slug(a)).toBe('team_app');
expect(slug(b)).toBe('app');
expect(slugFromEnvironment(state, b)).toBe('app');
expect(readVersionedSlugCache(state, b)).toBe('app');
expect(readVersionedSlugCache(state, a)).toBe('team_app');
});
test('--adopt-legacy --under-root refuses where no marker applies', () => {
const { proj } = workspace();
const r = run(SLUG, proj, ['--adopt-legacy', '--from', 'me-workspace', '--under-root']);
expect(r.status).toBe(2);
expect(r.stderr).toContain('no .gstack-project applies here');
});
});
describe('lib/bin-context: marker twin', () => {
test('slugFromEnvironment and the hook reader follow the marker, and both forget it on removal', () => {
const { proj, app } = workspace();
expect(slugFromEnvironment(state, app)).toBe('me-workspace');
expect(readVersionedSlugCache(state, app)).toBe('me-workspace');
mark(proj);
expect(readVersionedSlugCache(state, app)).toBeUndefined();
expect(slugFromEnvironment(state, app)).toBe('org-proj');
expect(readVersionedSlugCache(state, app)).toBe('org-proj');
fs.rmSync(path.join(proj, '.gstack-project'));
expect(readVersionedSlugCache(state, app)).toBe('me-workspace');
expect(slugFromEnvironment(state, app)).toBe('me-workspace');
});
test('bash and TS read each other\'s marker entries', () => {
const { top, backend } = umbrella();
mark(top);
expect(slugFromEnvironment(state, backend)).toBe('MyProduct');
fs.writeFileSync(path.join(state, 'slug-cache', 'marker', top.replace(/\//g, '_')), 'v2:from-ts');
expect(slug(backend)).toBe('from-ts');
fs.writeFileSync(path.join(state, 'slug-cache', 'marker', top.replace(/\//g, '_')), 'v2:from-bash');
expect(slugFromEnvironment(state, backend)).toBe('from-bash');
});
test('repository scope ignores every marker, so a committed marker cannot move pregate policy', () => {
const u = umbrella();
mark(u.top);
expect(slugFromEnvironment(state, u.backend)).toBe('MyProduct');
expect(slugFromEnvironment(state, u.backend, { scope: 'repository' })).toBe('org-backend');
const { proj, app } = workspace();
const before = defaultUserConfigPath(proj);
expect(before).toBe(path.join(state, 'projects', 'me-workspace', 'pregate.json'));
mark(proj);
git(['-C', proj, 'add', '.gstack-project']);
git(['-C', proj, 'commit', '-q', '-m', 'marker']);
expect(slugFromEnvironment(state, app)).toBe('org-proj');
expect(slugFromEnvironment(state, app, { scope: 'repository' })).toBe('me-workspace');
expect(defaultUserConfigPath(proj)).toBe(before);
});
test('a linked worktree follows the umbrella natively and stops when the marker goes', () => {
const { top, backend } = umbrella();
git(['-C', backend, 'commit', '-q', '--allow-empty', '-m', 'init']);
mark(top);
expect(slugFromEnvironment(state, backend)).toBe('MyProduct');
const wt = path.join(home, 'elsewhere', 'wt');
git(['-C', backend, 'worktree', 'add', '-q', wt]);
expect(projectMarkerRoot(wt)).toBe(top);
expect(slugFromEnvironment(state, wt)).toBe('MyProduct');
expect(readVersionedSlugCache(state, wt)).toBe('MyProduct');
fs.rmSync(path.join(top, '.gstack-project'));
expect(projectMarkerRoot(wt)).toBe('');
expect(slugFromEnvironment(state, wt)).toBe('org-backend');
expect(slug(wt)).toBe('org-backend');
});
test('subfolder and $HOME markers are ignored natively too', () => {
const { ws } = workspace();
const sub = path.join(ws, 'docs');
fs.mkdirSync(sub);
mark(sub);
mark(home);
expect(slugFromEnvironment(state, sub)).toBe('me-workspace');
});
});