From a07bf23e18642e528402ab301380ac18436f362b Mon Sep 17 00:00:00 2001 From: Huang Xin Date: Fri, 10 Apr 2026 01:42:47 +0800 Subject: [PATCH] chore(docs): add worktree management for isolated PR review and feature work (#3810) Co-authored-by: Claude Opus 4.6 (1M context) --- README.md | 2 + apps/readest-app/.claude/memory/MEMORY.md | 1 + .../.claude/memory/feedback_use_worktree.md | 18 ++ apps/readest-app/package.json | 4 +- apps/readest-app/scripts/worktree-new.ts | 278 ++++++++++++++++++ apps/readest-app/scripts/worktree-rm.ts | 51 ++++ apps/readest-app/tsconfig.json | 3 +- 7 files changed, 355 insertions(+), 2 deletions(-) create mode 100644 apps/readest-app/.claude/memory/feedback_use_worktree.md create mode 100644 apps/readest-app/scripts/worktree-new.ts create mode 100644 apps/readest-app/scripts/worktree-rm.ts diff --git a/README.md b/README.md index ef5ab305..24aca6b0 100644 --- a/README.md +++ b/README.md @@ -178,6 +178,7 @@ For Android: # Initialize the Android environment (run once) rm apps/readest-app/src-tauri/gen/android pnpm tauri android init +pnpm tauri icon ../../data/icons/readest-book.png git checkout apps/readest-app/src-tauri/gen/android pnpm tauri android dev @@ -190,6 +191,7 @@ For iOS: ```bash # Set up the iOS environment (run once) pnpm tauri ios init +pnpm tauri icon ../../data/icons/readest-book.png pnpm tauri ios dev # or if you want to dev on a real device diff --git a/apps/readest-app/.claude/memory/MEMORY.md b/apps/readest-app/.claude/memory/MEMORY.md index 79249ab1..dee4b67e 100644 --- a/apps/readest-app/.claude/memory/MEMORY.md +++ b/apps/readest-app/.claude/memory/MEMORY.md @@ -34,3 +34,4 @@ - [New branch per PR](feedback_pr_new_branch.md) — always create a fresh branch from main for each new PR/issue - [Upgrade gstack locally](feedback_gstack_upgrade.md) — always upgrade from the project's .claude/skills/gstack, not global - [No lookbehind regex](feedback_no_lookbehind_regex.md) — never use `(?<=)` or `(?` to create an isolated worktree before starting work on: +- Reviewing a GitHub PR (e.g., `pnpm worktree:new 3809`) → worktree at `~/dev/readest-pr-3809` +- Fixing a GitHub issue (e.g., `pnpm worktree:new fix/issue-123`) → worktree at `~/dev/readest-fix-issue-123` +- Implementing a feature request (e.g., `pnpm worktree:new feat/my-feature`) → worktree at `~/dev/readest-feat-my-feature` + +Worktree directory convention: `readest-` in the parent of the repo root (`~/dev/`), with slashes replaced by dashes. + +Use `pnpm worktree:rm ` to clean up when done. + +**Why:** Keeps the current bare repo branch untouched. Each task gets its own isolated workspace with submodules, dependencies, env files, and vendor assets already set up. + +**How to apply:** Before touching any code for a PR review, bug fix, or feature, run `pnpm worktree:new` first. Work inside the new worktree directory (e.g., `~/dev/readest-pr-3809/apps/readest-app/`). Clean up with `pnpm worktree:rm` after merging or finishing. diff --git a/apps/readest-app/package.json b/apps/readest-app/package.json index 5af77a23..660ed7a7 100644 --- a/apps/readest-app/package.json +++ b/apps/readest-app/package.json @@ -66,7 +66,9 @@ "check:translations": "count=$(grep -rno '__STRING_NOT_TRANSLATED__' public/locales/* | wc -l); if [ \"$count\" -gt 0 ]; then echo '❌ Untranslated strings found!'; exit 1; else echo '✅ All strings translated.'; fi", "check:lookbehind-regex": "count=$(grep -rnoE '\\(\\?<[!=]' .next/static/chunks/* out/_next/static/chunks/* | wc -l); if [ \"$count\" -gt 0 ]; then echo '❌ Lookbehind regex found in output!'; exit 1; else echo '✅ No lookbehind regex found.'; fi", "check:all": "pnpm check:translations && pnpm check:lookbehind-regex", - "build-check": "pnpm build && pnpm build-web && pnpm check:all" + "build-check": "pnpm build && pnpm build-web && pnpm check:all", + "worktree:new": "pnpm exec tsx scripts/worktree-new.ts", + "worktree:rm": "pnpm exec tsx scripts/worktree-rm.ts" }, "dependencies": { "@ai-sdk/react": "^3.0.49", diff --git a/apps/readest-app/scripts/worktree-new.ts b/apps/readest-app/scripts/worktree-new.ts new file mode 100644 index 00000000..1a40aedb --- /dev/null +++ b/apps/readest-app/scripts/worktree-new.ts @@ -0,0 +1,278 @@ +import { execSync, type StdioOptions } from 'node:child_process'; +import fs from 'node:fs'; +import path from 'node:path'; + +// Submodules skipped during worktree setup (shared via symlinks or pre-built) +const SKIPPED_SUBMODULES = [ + 'apps/readest-app/.claude/skills/gstack', // shared via .claude symlink + 'packages/simplecc-wasm', // built assets already in public/vendor +]; + +const arg = process.argv[2]; +if (!arg) { + console.error('Usage: pnpm worktree:new '); + process.exit(1); +} + +const repoRoot = execSync('git rev-parse --show-toplevel', { encoding: 'utf8' }).trim(); + +// Git output goes to stderr so stdout carries only the path (enables: cd $(pnpm worktree:new )) +const gitStdio: StdioOptions = ['inherit', process.stderr, process.stderr]; + +// Fetch origin so origin/main is up to date +console.error('--- Fetching origin ---'); +execSync('git fetch origin', { stdio: gitStdio, cwd: repoRoot }); + +let localBranch: string; +let worktreePath: string; + +if (/^\d+$/.test(arg)) { + // PR number -- fetch and set up remote tracking so `git push` works (even for forks) + localBranch = `pr-${arg}`; + worktreePath = path.join(path.dirname(repoRoot), `readest-${localBranch}`); + + // Get PR metadata to determine the source repo and branch + const prJson = execSync( + `gh pr view ${arg} --json headRefName,headRepositoryOwner,headRepository`, + { + encoding: 'utf8', + cwd: repoRoot, + }, + ); + const pr = JSON.parse(prJson) as { + headRefName: string; + headRepositoryOwner: { login: string }; + headRepository: { name: string }; + }; + const forkOwner = pr.headRepositoryOwner.login; + const forkRepo = pr.headRepository.name; + const remoteBranch = pr.headRefName; + + // Use "origin" if the PR is from the same repo, otherwise add the fork as a remote + const originUrl = execSync('git remote get-url origin', { + encoding: 'utf8', + cwd: repoRoot, + }).trim(); + const isFromOrigin = originUrl.includes(`/${forkOwner}/${forkRepo}`); + const remoteName = isFromOrigin ? 'origin' : forkOwner; + + if (!isFromOrigin) { + try { + execSync(`git remote get-url "${remoteName}"`, { encoding: 'utf8', cwd: repoRoot }); + } catch { + execSync(`git remote add "${remoteName}" "https://github.com/${forkOwner}/${forkRepo}.git"`, { + stdio: gitStdio, + cwd: repoRoot, + }); + } + } + + execSync(`git fetch "${remoteName}" "${remoteBranch}:${localBranch}"`, { + stdio: gitStdio, + cwd: repoRoot, + }); + execSync(`git worktree add "${worktreePath}" "${localBranch}"`, { + stdio: gitStdio, + cwd: repoRoot, + }); + + // Set upstream so `git push` targets the correct fork and branch. + // Use git-config directly instead of `branch --set-upstream-to` because + // the targeted fetch above doesn't create a remote-tracking ref. + execSync(`git -C "${worktreePath}" config "branch.${localBranch}.remote" "${remoteName}"`); + execSync( + `git -C "${worktreePath}" config "branch.${localBranch}.merge" "refs/heads/${remoteBranch}"`, + ); +} else { + // Branch name -- slashes replaced with dashes for the directory name + localBranch = arg; + worktreePath = path.join(path.dirname(repoRoot), `readest-${arg.replace(/\//g, '-')}`); + + if (fs.existsSync(worktreePath)) { + console.error(`Worktree path already exists: ${worktreePath}`); + console.error('Removing existing worktree...'); + // Deinit only submodules we manage — skipped ones were never initialized + const initedSubs = execSync( + 'git config --file .gitmodules --get-regexp "submodule\\..*\\.path"', + { encoding: 'utf8', cwd: worktreePath }, + ) + .trim() + .split('\n') + .map((line) => line.split(/\s+/)[1]!) + .filter((p) => !SKIPPED_SUBMODULES.includes(p)); + for (const sub of initedSubs) { + execSync(`git -C "${worktreePath}" submodule deinit --force -- "${sub}"`, { + stdio: gitStdio, + cwd: repoRoot, + }); + } + execSync(`git worktree remove --force "${worktreePath}"`, { stdio: gitStdio, cwd: repoRoot }); + } + + // Check if the branch already exists + const branchExists = execSync('git branch --list --format="%(refname:short)"', { + encoding: 'utf8', + cwd: repoRoot, + }) + .split('\n') + .includes(localBranch); + + if (branchExists) { + execSync(`git worktree add "${worktreePath}" "${localBranch}"`, { + stdio: gitStdio, + cwd: repoRoot, + }); + } else { + execSync(`git worktree add -b "${localBranch}" "${worktreePath}" origin/main`, { + stdio: gitStdio, + cwd: repoRoot, + }); + } +} + +// Rebase onto origin/main so the worktree starts from the latest upstream +console.error('\n--- Rebasing onto origin/main ---'); +execSync('git rebase origin/main', { stdio: gitStdio, cwd: worktreePath }); + +// Symlink shared directories into the new worktree, pointing at the bare repo's +// git common dir so all worktrees share the same settings without duplication. +const gitCommonDir = execSync('git rev-parse --git-common-dir', { + encoding: 'utf8', + cwd: repoRoot, +}).trim(); +for (const dir of ['.claude', '.local-settings']) { + const sharedDir = path.resolve(repoRoot, gitCommonDir, dir); + const newDir = path.join(worktreePath, dir); + fs.mkdirSync(sharedDir, { recursive: true }); + if (!fs.existsSync(newDir)) { + // 'junction' works without elevated privileges on Windows; ignored on Unix + fs.symlinkSync(sharedDir, newDir, 'junction'); + } +} + +// Repoint submodule URLs to local .git/modules/ clones to avoid remote fetches. +// Submodules without a local cache fall back to the remote URL. +console.error('\n--- Initializing submodules (using local objects) ---'); +const gitDir = execSync('git rev-parse --git-dir', { encoding: 'utf8', cwd: repoRoot }).trim(); +const absGitDir = path.resolve(repoRoot, gitDir); +const submoduleNames = execSync( + 'git config --file .gitmodules --get-regexp "submodule\\..*\\.path"', + { encoding: 'utf8', cwd: worktreePath }, +) + .trim() + .split('\n') + .map((line) => { + // line: submodule..path + const match = line.match(/^submodule\.(.+)\.path\s+(.+)$/); + return { name: match![1]!, subPath: match![2]! }; + }) + .filter(({ subPath }) => !SKIPPED_SUBMODULES.includes(subPath)); + +for (const { name, subPath } of submoduleNames) { + const localModuleDir = path.join(absGitDir, 'modules', subPath); + // Also check if the submodule has a full .git/ directory (cloned outside of git's modules cache) + const subGitDir = path.join(repoRoot, subPath, '.git'); + let localDir: string | undefined; + if (fs.existsSync(localModuleDir)) { + localDir = localModuleDir; + console.error(` ${subPath} -> local (.git/modules)`); + } else if (fs.existsSync(subGitDir)) { + localDir = fs.statSync(subGitDir).isDirectory() + ? subGitDir + : path.resolve( + path.join(repoRoot, subPath), + fs.readFileSync(subGitDir, 'utf8').replace('gitdir: ', '').trim(), + ); + console.error(` ${subPath} -> local (standalone .git)`); + } else { + console.error(` ${subPath} -> remote (no local cache)`); + } + if (localDir) { + // Allow fetching any commit (not just branch tips) from the local source + execSync(`git -C "${localDir}" config uploadpack.allowAnySHA1InWant true`); + execSync(`git -C "${worktreePath}" config "submodule.${name}.url" "${localDir}"`); + } +} + +for (const { subPath } of submoduleNames) { + execSync( + `git -c protocol.file.allow=always submodule update --init --recursive -- "${subPath}"`, + { stdio: gitStdio, cwd: worktreePath }, + ); +} + +// Restore original remote URLs so `git push` in submodules works correctly +for (const { name } of submoduleNames) { + const origUrl = execSync(`git config --file .gitmodules "submodule.${name}.url"`, { + encoding: 'utf8', + cwd: worktreePath, + }).trim(); + execSync(`git -C "${worktreePath}" config "submodule.${name}.url" "${origUrl}"`); +} + +// Install dependencies +console.error('\n--- Installing dependencies ---'); +execSync('pnpm install', { stdio: gitStdio, cwd: worktreePath }); + +// Copy .env* files from the app directory to the new worktree's app directory +const appRelPath = 'apps/readest-app'; +const srcAppDir = path.join(repoRoot, appRelPath); +const dstAppDir = path.join(worktreePath, appRelPath); +const envFiles = fs.readdirSync(srcAppDir).filter((f) => f.startsWith('.env')); +if (envFiles.length > 0) { + console.error(`\n--- Copying ${envFiles.length} .env* files ---`); + for (const envFile of envFiles) { + const src = path.join(srcAppDir, envFile); + const dst = path.join(dstAppDir, envFile); + if (!fs.existsSync(dst)) { + fs.copyFileSync(src, dst); + console.error(` ${envFile}`); + } + } +} + +// Symlink target so the worktree shares the Rust build cache +const srcTarget = path.join(repoRoot, 'target'); +const dstTarget = path.join(worktreePath, 'target'); +if (fs.existsSync(srcTarget) && !fs.existsSync(dstTarget)) { + console.error('\n--- Symlinking src-tauri/target ---'); + fs.symlinkSync(srcTarget, dstTarget, 'junction'); +} + +// Initialize Tauri Android gen directory (needs platform-specific paths regenerated) +const genDir = path.join(dstAppDir, 'src-tauri', 'gen'); +const androidGenDir = path.join(genDir, 'android'); +if (fs.existsSync(androidGenDir)) { + console.error('\n--- Initializing Tauri Android ---'); + fs.rmSync(androidGenDir, { recursive: true }); + execSync('pnpm tauri android init', { stdio: gitStdio, cwd: dstAppDir }); + execSync('pnpm tauri icon ../../data/icons/readest-book.png', { + stdio: gitStdio, + cwd: dstAppDir, + }); + execSync(`git checkout ${appRelPath}/src-tauri/gen/android`, { + stdio: gitStdio, + cwd: worktreePath, + }); +} + +// Symlink Tauri gen/apple and gen/schemas from the main worktree +for (const sub of ['apple', 'schemas', 'android/keystore.properties']) { + const src = path.join(srcAppDir, 'src-tauri', 'gen', sub); + const dst = path.join(genDir, sub); + if (fs.existsSync(src) && !fs.existsSync(dst)) { + console.error(` Symlinking src-tauri/gen/${sub}`); + fs.symlinkSync(src, dst, 'junction'); + } +} + +// Copy public/vendor to the new worktree (built assets not in git) +const srcVendor = path.join(srcAppDir, 'public', 'vendor'); +const dstVendor = path.join(dstAppDir, 'public', 'vendor'); +if (fs.existsSync(srcVendor) && !fs.existsSync(dstVendor)) { + console.error('\n--- Copying public/vendor ---'); + fs.cpSync(srcVendor, dstVendor, { recursive: true }); +} + +// Print path to stdout -- allows: cd $(pnpm worktree:new ) +process.stdout.write(worktreePath + '\n'); diff --git a/apps/readest-app/scripts/worktree-rm.ts b/apps/readest-app/scripts/worktree-rm.ts new file mode 100644 index 00000000..517027eb --- /dev/null +++ b/apps/readest-app/scripts/worktree-rm.ts @@ -0,0 +1,51 @@ +import { execSync, type StdioOptions } from 'node:child_process'; +import path from 'node:path'; + +const SKIPPED_SUBMODULES = ['apps/readest-app/.claude/skills/gstack', 'packages/simplecc-wasm']; + +const arg = process.argv[2]; +if (!arg) { + console.error('Usage: pnpm worktree:rm '); + process.exit(1); +} + +const repoRoot = execSync('git rev-parse --show-toplevel', { encoding: 'utf8' }).trim(); +const gitStdio: StdioOptions = ['inherit', process.stderr, process.stderr]; + +// Resolve worktree path from the argument +let dirName: string; +if (/^\d+$/.test(arg)) { + dirName = `readest-pr-${arg}`; +} else { + dirName = `readest-${arg.replace(/\//g, '-')}`; +} +const worktreePath = path.join(path.dirname(repoRoot), dirName); + +// Check the worktree exists +const worktrees = execSync('git worktree list --porcelain', { encoding: 'utf8', cwd: repoRoot }); +const found = worktrees.split('\n').some((line) => line === `worktree ${worktreePath}`); +if (!found) { + console.error(`error: no worktree found at ${worktreePath}`); + process.exit(1); +} + +console.error(`Removing worktree: ${worktreePath}`); + +// Deinit only submodules we manage — skipped ones were never initialized +const initedSubs = execSync('git config --file .gitmodules --get-regexp "submodule\\..*\\.path"', { + encoding: 'utf8', + cwd: worktreePath, +}) + .trim() + .split('\n') + .map((line) => line.split(/\s+/)[1]!) + .filter((p) => !SKIPPED_SUBMODULES.includes(p)); +for (const sub of initedSubs) { + execSync(`git -C "${worktreePath}" submodule deinit --force -- "${sub}"`, { + stdio: gitStdio, + cwd: repoRoot, + }); +} +execSync(`git worktree remove --force "${worktreePath}"`, { stdio: gitStdio, cwd: repoRoot }); + +console.error('Done.'); diff --git a/apps/readest-app/tsconfig.json b/apps/readest-app/tsconfig.json index 2708abaf..ee86145e 100644 --- a/apps/readest-app/tsconfig.json +++ b/apps/readest-app/tsconfig.json @@ -33,7 +33,8 @@ "src/**/*.ts", "src/**/*.tsx", "raw-loader.d.ts", - ".next/types/**/*.ts" + ".next/types/**/*.ts", + "scripts/**/*.ts" ], "exclude": ["node_modules", "out"] }