pulled store art never goes into the app repo: art pull --write, init and onboard add the renders folder (relative to the repo root, e.g. /store/renders/) to the repo's .gitignore when git check-ignore says it is not ignored yet, with one line saying so; tests

This commit is contained in:
CRTkafa 2026-10-14 04:31:55 +08:00
parent 81915651bd
commit fb3a7c186b
6 changed files with 69 additions and 3 deletions

View file

@ -12,7 +12,8 @@
`apple screenshots` / `apple previews` see a pulled file as the store's and upload nothing. An upsert: a re-run `apple screenshots` / `apple previews` see a pulled file as the store's and upload nothing. An upsert: a re-run
changes nothing, a store change updates an unedited pull, and a local file that differs (your own render, an edit changes nothing, a store change updates an unedited pull, and a local file that differs (your own render, an edit
after the pull) is kept and listed unless `--force`. Read-only on the stores. `storebound onboard` runs it too after the pull) is kept and listed unless `--force`. Read-only on the stores. `storebound onboard` runs it too
(`--no-art` skips it). (`--no-art` skips it). Pulled art never goes into git: when the app folder is in a repo and the renders folder is
not ignored yet, `art pull --write` (and `init` / `onboard`) add it to the repo root .gitignore (e.g. `/store/renders/`).
- **check / validate with pulled art.** Missing render sources (the icon artwork, the logo, screenshot captions) are - **check / validate with pulled art.** Missing render sources (the icon artwork, the logo, screenshot captions) are
notes instead of problems once the art is pulled; pulled files pass the local store checks as the store's own, and notes instead of problems once the art is pulled; pulled files pass the local store checks as the store's own, and
a set the store had nothing for (a Play language on the default language's graphics, a locale without that set) is a set the store had nothing for (a Play language on the default language's graphics, a locale without that set) is

View file

@ -30,6 +30,7 @@ import {
posix, type Pulled, PULLED_FILE, readPulled, type RemoteArt, sha256Of, slotOf, sniffExt, storeEmptySets, summarize, thumbSize, posix, type Pulled, PULLED_FILE, readPulled, type RemoteArt, sha256Of, slotOf, sniffExt, storeEmptySets, summarize, thumbSize,
} from './lib/art-pull'; } from './lib/art-pull';
import { playClient } from './play/client'; import { playClient } from './play/client';
import { ignoreInRepo } from './lib/git-ignore';
import { decodePng, hashGray, imageSimilarity } from './lib/imagehash'; import { decodePng, hashGray, imageSimilarity } from './lib/imagehash';
import { IMAGE_SPECS, checkImage, imageInfo } from './shots/spec'; import { IMAGE_SPECS, checkImage, imageInfo } from './shots/spec';
@ -260,6 +261,8 @@ export interface PullReport {
storeEmpty: string[]; storeEmpty: string[];
notes: string[]; notes: string[];
written: boolean; written: boolean;
/** The line added to the app repo's ignore file (pulled art is never committed), or null. */
gitignored?: string | null;
manifest: string; manifest: string;
} }
@ -365,6 +368,8 @@ export async function artPull(ctx: Ctx, o: { store?: string; write: boolean; for
writeFileSync(join(p.renders, PULLED_FILE), JSON.stringify(next, null, 2) + '\n'); writeFileSync(join(p.renders, PULLED_FILE), JSON.stringify(next, null, 2) + '\n');
report.written = true; report.written = true;
} }
// pulled store art never goes into the app's repo
if (existsSync(p.renders)) report.gitignored = ignoreInRepo(p.renders, 'storebound: store art (rendered or pulled from the stores), never committed');
if (failed.length) { report.notes.push(...failed.map(f => `failed: ${f}`)); process.exitCode = 1; } if (failed.length) { report.notes.push(...failed.map(f => `failed: ${f}`)); process.exitCode = 1; }
return report; return report;
} }
@ -387,6 +392,7 @@ export function printPull(r: PullReport, write: boolean): void {
for (const s of r.stores) (s.ok ? log : warn)(TAG, s.ok ? `${s.store === 'apple' ? 'App Store' : 'Google Play'}: ${s.images} image(s)${s.version ? `, version ${s.version}` : ''}` : `${s.store}: could not read: ${s.error}`); for (const s of r.stores) (s.ok ? log : warn)(TAG, s.ok ? `${s.store === 'apple' ? 'App Store' : 'Google Play'}: ${s.images} image(s)${s.version ? `, version ${s.version}` : ''}` : `${s.store}: could not read: ${s.error}`);
if (r.steps.length) console.log(textTable(['action', 'files', 'why'], groupSteps(r.steps))); if (r.steps.length) console.log(textTable(['action', 'files', 'why'], groupSteps(r.steps)));
for (const u of r.unmapped) log(TAG, `${u}: no language in the config has this locale (not pulled)`); for (const u of r.unmapped) log(TAG, `${u}: no language in the config has this locale (not pulled)`);
if (r.gitignored) log(TAG, `.gitignore: added ${r.gitignored} (store art stays out of the repo)`);
for (const n of r.notes) warn(TAG, n); for (const n of r.notes) warn(TAG, n);
if (r.conflicts.length) warn(TAG, `${r.conflicts.length} local file(s) differ from the store and are kept: ${r.conflicts.slice(0, 8).join(', ')}${r.conflicts.length > 8 ? ', ...' : ''} (--force replaces them with the store's)`); if (r.conflicts.length) warn(TAG, `${r.conflicts.length} local file(s) differ from the store and are kept: ${r.conflicts.slice(0, 8).join(', ')}${r.conflicts.length > 8 ? ', ...' : ''} (--force replaces them with the store's)`);
const s = r.summary; const s = r.summary;

View file

@ -9,6 +9,7 @@ import { appendFileSync, existsSync, mkdirSync, readFileSync, writeFileSync } fr
import { dirname, join, relative, resolve } from 'node:path'; import { dirname, join, relative, resolve } from 'node:path';
import type { Args } from './lib/args'; import type { Args } from './lib/args';
import { type AppProfile, PROFILES } from './lib/config'; import { type AppProfile, PROFILES } from './lib/config';
import { ignoreInRepo } from './lib/git-ignore';
import { log } from './lib/log'; import { log } from './lib/log';
import { TEMPLATE_TEXT } from './lib/template-files'; import { TEMPLATE_TEXT } from './lib/template-files';
import * as rt from './lib/rt'; import * as rt from './lib/rt';
@ -70,5 +71,8 @@ export async function run(args: Args): Promise<void> {
else writeFileSync(gi, '# storebound: credentials, fonts, temp files\n.storebound/\n'); else writeFileSync(gi, '# storebound: credentials, fonts, temp files\n.storebound/\n');
log('init', '.gitignore: added .storebound/'); log('init', '.gitignore: added .storebound/');
} }
// store art (rendered or pulled from the stores) is never committed
const art = ignoreInRepo(join(root, 'store', 'renders'), 'storebound: store art (rendered or pulled from the stores), never committed');
if (art) log('init', `.gitignore: added ${art}`);
log('init', 'next: edit storebound.config.ts, store/*.md and .storebound/credentials.json, then `storebound check`; store/workflows/*.yml: storebound flow list'); log('init', 'next: edit storebound.config.ts, store/*.md and .storebound/credentials.json, then `storebound check`; store/workflows/*.yml: storebound flow list');
} }

28
cli/lib/git-ignore.ts Normal file
View file

@ -0,0 +1,28 @@
// Keep a folder out of the app's git repo: store art pulled from the stores (and renders) is never committed.
import { appendFileSync, existsSync, readFileSync, realpathSync, writeFileSync } from 'node:fs';
import { spawnSync } from 'node:child_process';
import { join, relative, sep } from 'node:path';
const git = (cwd: string, args: string[]) => spawnSync('git', args, { cwd, encoding: 'utf8', timeout: 15_000 });
/** When `dir` is inside a git repo and not ignored yet, adds `/<dir relative to the repo root>/` to the repo root's
* .gitignore. Returns the line added, or null (no repo, no git, already ignored). `dry`: only says what it would add. */
export function ignoreInRepo(dir: string, comment: string, dry = false): string | null {
let probe = dir;
while (!existsSync(probe)) { const up = join(probe, '..'); if (up === probe) return null; probe = up; }
const top = git(probe, ['rev-parse', '--show-toplevel']);
if (top.status !== 0) return null;
const root = realpathSync(top.stdout.trim());
let real: string;
try { real = realpathSync(probe) + dir.slice(probe.length); } catch { return null; }
const rel = relative(root, real).split(sep).join('/');
if (!rel || rel.startsWith('..')) return null;
if (git(root, ['check-ignore', '-q', `${rel}/`]).status === 0) return null;
const line = `/${rel}/`;
if (dry) return line;
const gi = join(root, '.gitignore');
const cur = existsSync(gi) ? readFileSync(gi, 'utf8') : '';
const add = `# ${comment}\n${line}\n`;
if (cur) appendFileSync(gi, `${cur.endsWith('\n') ? '' : '\n'}${add}`); else writeFileSync(gi, add);
return line;
}

View file

@ -25,8 +25,8 @@ Do not render art the stores already have. For an existing app (`storebound onbo
4. Change one image: edit or replace the file (desktop: Store art > open the image > Replace with a file), then the 4. Change one image: edit or replace the file (desktop: Store art > open the image > Replace with a file), then the
upload's dry run shows exactly that set. Render new art (the steps below) only when the owner wants new screenshots. upload's dry run shows exactly that set. Render new art (the steps below) only when the owner wants new screenshots.
Old display types without a size key here (e.g. `APP_IPHONE_55`) land in `apple-<type>/` and are kept, never checked Old display types without a size key here (e.g. `APP_IPHONE_55`) land in `apple-<type>/` and are kept, never checked
or uploaded as another size. `store/renders` can be large (hundreds of MB): keep it out of git unless the project or uploaded as another size. `store/renders` can be large (hundreds of MB) and never goes into git: `art pull --write`,
wants it there. `init` and `onboard` add it (`/store/renders/`, relative to the repo root) to the repo's .gitignore when it is not ignored yet.
## Sizes (what each store wants) ## Sizes (what each store wants)
| size key | pixels | where | | size key | pixels | where |

View file

@ -285,6 +285,33 @@ describe('art pull against recorded store shapes', () => {
}); });
}); });
describe('pulled art stays out of the app repo', () => {
const sh = (cwd: string, ...a: string[]) => Bun.spawnSync(['git', ...a], { cwd });
test('art pull --write in a repo adds /store/renders/ to the repo root .gitignore once; a subfolder app gets its path', async () => {
const p = setup({ 'app/.keep': '', '.gitignore': 'node_modules\n' });
sh(p.root, 'init', '-q');
stores();
const r = await artPull(p.ctx(['--write']), { write: true, force: false, quiet: true });
expect(r.gitignored).toBe('/store/renders/');
expect(readFileSync(join(p.root, '.gitignore'), 'utf8')).toBe('node_modules\n# storebound: store art (rendered or pulled from the stores), never committed\n/store/renders/\n');
expect(sh(p.root, 'check-ignore', '-q', 'store/renders/en/ios69/01.png').exitCode).toBe(0);
stores();
expect((await artPull(p.ctx(['--write']), { write: true, force: false, quiet: true })).gitignored).toBeNull();
// an app folder inside a bigger repo: the path relative to the repo root
const { ignoreInRepo } = await import('../cli/lib/git-ignore');
expect(ignoreInRepo(join(p.root, 'app', 'store', 'renders'), 'x', true)).toBe('/app/store/renders/');
});
test('no repo: nothing written; the dry run writes nothing either', async () => {
const p = setup();
stores();
const dry = await artPull(p.ctx([]), { write: false, force: false, quiet: true });
expect(dry.gitignored).toBeUndefined();
stores();
expect((await artPull(p.ctx(['--write']), { write: true, force: false, quiet: true })).gitignored).toBeNull();
expect(existsSync(join(p.root, '.gitignore'))).toBe(false);
});
});
describe('MCP / desktop', () => { describe('MCP / desktop', () => {
test('art_pull is a dry run unless dry_run is false; --write only then, never --force', () => { test('art_pull is a dry run unless dry_run is false; --write only then, never --force', () => {
const t = TOOLS.find(x => x.name === 'art_pull')!; const t = TOOLS.find(x => x.name === 'art_pull')!;