diff --git a/CHANGELOG.md b/CHANGELOG.md index 277c96c..87d6e0e 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -12,7 +12,8 @@ `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 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 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 diff --git a/cli/art.ts b/cli/art.ts index 0633995..f1d7eac 100644 --- a/cli/art.ts +++ b/cli/art.ts @@ -30,6 +30,7 @@ import { posix, type Pulled, PULLED_FILE, readPulled, type RemoteArt, sha256Of, slotOf, sniffExt, storeEmptySets, summarize, thumbSize, } from './lib/art-pull'; import { playClient } from './play/client'; +import { ignoreInRepo } from './lib/git-ignore'; import { decodePng, hashGray, imageSimilarity } from './lib/imagehash'; import { IMAGE_SPECS, checkImage, imageInfo } from './shots/spec'; @@ -260,6 +261,8 @@ export interface PullReport { storeEmpty: string[]; notes: string[]; written: boolean; + /** The line added to the app repo's ignore file (pulled art is never committed), or null. */ + gitignored?: string | null; 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'); 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; } 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}`); 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)`); + 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); 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; diff --git a/cli/init.ts b/cli/init.ts index 615828b..bb77b19 100644 --- a/cli/init.ts +++ b/cli/init.ts @@ -9,6 +9,7 @@ import { appendFileSync, existsSync, mkdirSync, readFileSync, writeFileSync } fr import { dirname, join, relative, resolve } from 'node:path'; import type { Args } from './lib/args'; import { type AppProfile, PROFILES } from './lib/config'; +import { ignoreInRepo } from './lib/git-ignore'; import { log } from './lib/log'; import { TEMPLATE_TEXT } from './lib/template-files'; import * as rt from './lib/rt'; @@ -70,5 +71,8 @@ export async function run(args: Args): Promise { else writeFileSync(gi, '# storebound: credentials, fonts, temp files\n.storebound/\n'); 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'); } diff --git a/cli/lib/git-ignore.ts b/cli/lib/git-ignore.ts new file mode 100644 index 0000000..9b23d96 --- /dev/null +++ b/cli/lib/git-ignore.ts @@ -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 `//` 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; +} diff --git a/skills/store-screenshots/SKILL.md b/skills/store-screenshots/SKILL.md index cda6d3f..b6d37ba 100644 --- a/skills/store-screenshots/SKILL.md +++ b/skills/store-screenshots/SKILL.md @@ -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 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-/` 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 -wants it there. +or uploaded as another size. `store/renders` can be large (hundreds of MB) and never goes into git: `art pull --write`, +`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) | size key | pixels | where | diff --git a/tests/art-pull.test.ts b/tests/art-pull.test.ts index 682edda..48be153 100644 --- a/tests/art-pull.test.ts +++ b/tests/art-pull.test.ts @@ -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', () => { 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')!;