chore(docs): add worktree management for isolated PR review and feature work (#3810)
Co-authored-by: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -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
|
||||
|
||||
@@ -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 `(?<!)` in JS/TS; build check rejects them
|
||||
- [Use worktree](feedback_use_worktree.md) — always `pnpm worktree:new` before PR review, issue fix, or feature work
|
||||
|
||||
@@ -0,0 +1,18 @@
|
||||
---
|
||||
name: Use worktree for PR/issue/feature work
|
||||
description: Always create a git worktree with pnpm worktree:new before reviewing PRs, fixing issues, or implementing features
|
||||
type: feedback
|
||||
originSessionId: 650f8ff2-980d-459f-ad23-ba0af56e28b5
|
||||
---
|
||||
Always use `pnpm worktree:new <branch-name|pr-number>` 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-<name>` in the parent of the repo root (`~/dev/`), with slashes replaced by dashes.
|
||||
|
||||
Use `pnpm worktree:rm <branch-name|pr-number>` 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.
|
||||
@@ -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",
|
||||
|
||||
@@ -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 <branch-name|pr-number>');
|
||||
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 <arg>))
|
||||
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.<name>.path <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 <arg>)
|
||||
process.stdout.write(worktreePath + '\n');
|
||||
@@ -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 <branch-name|pr-number>');
|
||||
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.');
|
||||
@@ -33,7 +33,8 @@
|
||||
"src/**/*.ts",
|
||||
"src/**/*.tsx",
|
||||
"raw-loader.d.ts",
|
||||
".next/types/**/*.ts"
|
||||
".next/types/**/*.ts",
|
||||
"scripts/**/*.ts"
|
||||
],
|
||||
"exclude": ["node_modules", "out"]
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user