-
Notifications
You must be signed in to change notification settings - Fork 698
feat: add --worktree flag for isolated write-capable rescue tasks #137
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
base: main
Are you sure you want to change the base?
Changes from all commits
7eaace5
309c2d4
6b6472f
4bca062
70727cf
256df3a
4fe8077
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| Original file line number | Diff line number | Diff line change |
|---|---|---|
|
|
@@ -187,6 +187,73 @@ function collectBranchContext(cwd, baseRef) { | |
| }; | ||
| } | ||
|
|
||
| export function createWorktree(repoRoot) { | ||
| const ts = Date.now(); | ||
| const worktreesDir = path.join(repoRoot, ".worktrees"); | ||
| fs.mkdirSync(worktreesDir, { recursive: true }); | ||
|
Comment on lines
+192
to
+193
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more.
Creating the worktree under Useful? React with 👍 / 👎.
Author
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. The dirty-state concern is valid and we've addressed it, but we can't move worktrees outside the repo root. Claude Code's permission sandbox is scoped to the project directory — a worktree at an external path (e.g. Instead, |
||
|
|
||
| // Ensure .worktrees/ is excluded from the target repo without modifying tracked files. | ||
| // Use git rev-parse to resolve the real git dir (handles linked worktrees where .git is a file). | ||
| const rawGitDir = gitChecked(repoRoot, ["rev-parse", "--git-dir"]).stdout.trim(); | ||
| const gitDir = path.resolve(repoRoot, rawGitDir); | ||
| const excludePath = path.join(gitDir, "info", "exclude"); | ||
| const excludeContent = fs.existsSync(excludePath) ? fs.readFileSync(excludePath, "utf8") : ""; | ||
| if (!excludeContent.includes(".worktrees")) { | ||
| fs.mkdirSync(path.dirname(excludePath), { recursive: true }); | ||
| fs.appendFileSync(excludePath, `${excludeContent.endsWith("\n") || !excludeContent ? "" : "\n"}.worktrees/\n`); | ||
| } | ||
|
|
||
| const worktreePath = path.join(worktreesDir, `codex-${ts}`); | ||
| const branch = `codex/${ts}`; | ||
| const baseCommit = gitChecked(repoRoot, ["rev-parse", "HEAD"]).stdout.trim(); | ||
| gitChecked(repoRoot, ["worktree", "add", worktreePath, "-b", branch]); | ||
| return { worktreePath, branch, repoRoot, baseCommit, timestamp: ts }; | ||
| } | ||
|
|
||
| export function removeWorktree(repoRoot, worktreePath) { | ||
| const result = git(repoRoot, ["worktree", "remove", "--force", worktreePath]); | ||
| if (result.status !== 0 && !result.stderr.includes("is not a working tree")) { | ||
| throw new Error(`Failed to remove worktree: ${result.stderr.trim()}`); | ||
| } | ||
| } | ||
|
|
||
| export function deleteWorktreeBranch(repoRoot, branch) { | ||
| git(repoRoot, ["branch", "-D", branch]); | ||
| } | ||
|
|
||
| export function getWorktreeDiff(worktreePath, baseCommit) { | ||
| git(worktreePath, ["add", "-A"]); | ||
| const result = git(worktreePath, ["diff", "--cached", baseCommit, "--stat"]); | ||
| if (result.status !== 0 || !result.stdout.trim()) { | ||
| return { stat: "", patch: "" }; | ||
| } | ||
| const stat = result.stdout.trim(); | ||
| const patchResult = gitChecked(worktreePath, ["diff", "--cached", baseCommit]); | ||
| return { stat, patch: patchResult.stdout }; | ||
| } | ||
|
|
||
| export function applyWorktreePatch(repoRoot, worktreePath, baseCommit) { | ||
| git(worktreePath, ["add", "-A"]); | ||
| const patchResult = git(worktreePath, ["diff", "--cached", baseCommit]); | ||
| if (patchResult.status !== 0 || !patchResult.stdout.trim()) { | ||
| return { applied: false, detail: "No changes to apply." }; | ||
| } | ||
| const patchPath = path.join( | ||
| repoRoot, | ||
| ".codex-worktree-" + Date.now() + "-" + Math.random().toString(16).slice(2) + ".patch" | ||
| ); | ||
| try { | ||
| fs.writeFileSync(patchPath, patchResult.stdout, "utf8"); | ||
| const applyResult = git(repoRoot, ["apply", "--index", patchPath]); | ||
| if (applyResult.status !== 0) { | ||
| return { applied: false, detail: applyResult.stderr.trim() || "Patch apply failed (conflicts?)." }; | ||
| } | ||
| return { applied: true, detail: "Changes applied and staged." }; | ||
| } finally { | ||
| fs.rmSync(patchPath, { force: true }); | ||
| } | ||
| } | ||
|
|
||
| export function collectReviewContext(cwd, target) { | ||
| const repoRoot = getRepoRoot(cwd); | ||
| const state = getWorkingTreeState(cwd); | ||
|
|
||
| Original file line number | Diff line number | Diff line change |
|---|---|---|
|
|
@@ -445,6 +445,66 @@ export function renderStoredJobResult(job, storedJob) { | |
| return `${lines.join("\n").trimEnd()}\n`; | ||
| } | ||
|
|
||
| export function renderWorktreeTaskResult(execution, session, diff, { jobId = null } = {}) { | ||
| const lines = []; | ||
|
|
||
| if (execution.rendered) { | ||
| lines.push(execution.rendered.trimEnd()); | ||
| lines.push(""); | ||
| } | ||
|
|
||
| lines.push("---"); | ||
| lines.push(""); | ||
| lines.push("## Worktree"); | ||
| lines.push(""); | ||
| lines.push(`Branch: \`${session.branch}\``); | ||
| lines.push(`Path: \`${session.worktreePath}\``); | ||
| lines.push(""); | ||
|
|
||
| if (diff.stat) { | ||
| lines.push("### Changes"); | ||
| lines.push(""); | ||
| lines.push("```"); | ||
| lines.push(diff.stat); | ||
| lines.push("```"); | ||
| lines.push(""); | ||
| } else { | ||
| lines.push("Codex made no file changes in the worktree."); | ||
| lines.push(""); | ||
| } | ||
|
|
||
| lines.push("### Actions"); | ||
| lines.push(""); | ||
| lines.push("Apply these changes to your working tree, or discard them:"); | ||
| lines.push(""); | ||
| const resolvedJobId = jobId ?? "JOB_ID"; | ||
| const cwdFlag = session.repoRoot ? ` --cwd "${session.repoRoot}"` : ""; | ||
| lines.push(`- **Keep**: \`node "\${CLAUDE_PLUGIN_ROOT}/scripts/codex-companion.mjs" worktree-cleanup ${resolvedJobId} --action keep${cwdFlag}\``); | ||
| lines.push(`- **Discard**: \`node "\${CLAUDE_PLUGIN_ROOT}/scripts/codex-companion.mjs" worktree-cleanup ${resolvedJobId} --action discard${cwdFlag}\``); | ||
|
|
||
| return `${lines.join("\n").trimEnd()}\n`; | ||
| } | ||
|
|
||
| export function renderWorktreeCleanupResult(action, result, session) { | ||
| const lines = ["# Worktree Cleanup", ""]; | ||
|
|
||
| if (action === "keep") { | ||
| if (result.applied) { | ||
| lines.push(`Applied changes from \`${session.branch}\` and cleaned up.`); | ||
| } else if (result.detail === "No changes to apply.") { | ||
| lines.push(`No changes to apply. Worktree and branch \`${session.branch}\` cleaned up.`); | ||
| } else { | ||
| lines.push(`Failed to apply changes: ${result.detail}`); | ||
| lines.push(""); | ||
| lines.push(`The worktree and branch \`${session.branch}\` have been preserved at \`${session.worktreePath}\` for manual recovery.`); | ||
| } | ||
|
Comment on lines
+496
to
+500
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more.
When Useful? React with 👍 / 👎.
Author
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Fixed in peterdrier/codex-plugin-cc@92a3126 — added an explicit branch for |
||
| } else { | ||
| lines.push(`Discarded worktree \`${session.worktreePath}\` and branch \`${session.branch}\`.`); | ||
| } | ||
|
|
||
| return `${lines.join("\n").trimEnd()}\n`; | ||
| } | ||
|
|
||
| export function renderCancelReport(job) { | ||
| const lines = [ | ||
| "# Codex Cancel", | ||
|
|
||
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
The
worktree-cleanuphandler always exits successfully after rendering output, even when--action keepfails to apply the patch (result.applied === falsewith an error detail). In that conflict path the command reports a failure message but still returns exit code 0, so wrappers/automation cannot distinguish a successful keep from a preserved-worktree failure and may continue under a false success state. This should set a non-zero exit code for failed keep applies (while still allowing the explicit no-opNo changes to apply.case to remain successful).Useful? React with 👍 / 👎.