feat(task): add nested non-submodule repo support for isolation
This commit is contained in:
@@ -1,3 +1,4 @@
|
||||
import type { Dirent } from "node:fs";
|
||||
import * as fs from "node:fs/promises";
|
||||
import * as os from "node:os";
|
||||
import path from "node:path";
|
||||
@@ -5,13 +6,21 @@ import { isEnoent, Snowflake } from "@oh-my-pi/pi-utils";
|
||||
import { getWorktreeDir } from "@oh-my-pi/pi-utils/dirs";
|
||||
import { $ } from "bun";
|
||||
|
||||
export interface WorktreeBaseline {
|
||||
/** Baseline state for a single git repository. */
|
||||
export interface RepoBaseline {
|
||||
repoRoot: string;
|
||||
staged: string;
|
||||
unstaged: string;
|
||||
untracked: string[];
|
||||
}
|
||||
|
||||
/** Baseline state for the project, including any nested git repos. */
|
||||
export interface WorktreeBaseline {
|
||||
root: RepoBaseline;
|
||||
/** Nested git repos (path relative to root.repoRoot). */
|
||||
nested: Array<{ relativePath: string; baseline: RepoBaseline }>;
|
||||
}
|
||||
|
||||
export function getEncodedProjectName(cwd: string): string {
|
||||
return `--${cwd.replace(/^[/\\]/, "").replace(/[/\\:]/g, "-")}--`;
|
||||
}
|
||||
@@ -39,7 +48,55 @@ export async function ensureWorktree(baseCwd: string, id: string): Promise<strin
|
||||
return worktreeDir;
|
||||
}
|
||||
|
||||
export async function captureBaseline(repoRoot: string): Promise<WorktreeBaseline> {
|
||||
/** Find nested git repositories (non-submodule) under the given root. */
|
||||
async function discoverNestedRepos(repoRoot: string): Promise<string[]> {
|
||||
// Get submodule paths so we can exclude them
|
||||
const submoduleRaw = await $`git submodule --quiet foreach --recursive 'echo $sm_path'`
|
||||
.cwd(repoRoot)
|
||||
.quiet()
|
||||
.nothrow()
|
||||
.text();
|
||||
const submodulePaths = new Set(
|
||||
submoduleRaw
|
||||
.split("\n")
|
||||
.map(l => l.trim())
|
||||
.filter(Boolean),
|
||||
);
|
||||
|
||||
// Find all .git dirs/files that aren't the root or known submodules
|
||||
const result: string[] = [];
|
||||
async function walk(dir: string): Promise<void> {
|
||||
let entries: Dirent[];
|
||||
try {
|
||||
entries = await fs.readdir(dir, { withFileTypes: true });
|
||||
} catch {
|
||||
return;
|
||||
}
|
||||
for (const entry of entries) {
|
||||
if (entry.name === "node_modules" || entry.name === ".git") continue;
|
||||
if (!entry.isDirectory()) continue;
|
||||
const full = path.join(dir, entry.name);
|
||||
const rel = path.relative(repoRoot, full);
|
||||
// Check if this directory is itself a git repo
|
||||
const gitDir = path.join(full, ".git");
|
||||
let hasGit = false;
|
||||
try {
|
||||
await fs.access(gitDir);
|
||||
hasGit = true;
|
||||
} catch {}
|
||||
if (hasGit && !submodulePaths.has(rel)) {
|
||||
result.push(rel);
|
||||
// Don't recurse into nested repos — they manage their own tree
|
||||
continue;
|
||||
}
|
||||
await walk(full);
|
||||
}
|
||||
}
|
||||
await walk(repoRoot);
|
||||
return result;
|
||||
}
|
||||
|
||||
async function captureRepoBaseline(repoRoot: string): Promise<RepoBaseline> {
|
||||
const staged = await $`git diff --cached --binary`.cwd(repoRoot).quiet().text();
|
||||
const unstaged = await $`git diff --binary`.cwd(repoRoot).quiet().text();
|
||||
const untrackedRaw = await $`git ls-files --others --exclude-standard`.cwd(repoRoot).quiet().text();
|
||||
@@ -47,13 +104,18 @@ export async function captureBaseline(repoRoot: string): Promise<WorktreeBaselin
|
||||
.split("\n")
|
||||
.map(line => line.trim())
|
||||
.filter(line => line.length > 0);
|
||||
return { repoRoot, staged, unstaged, untracked };
|
||||
}
|
||||
|
||||
return {
|
||||
repoRoot,
|
||||
staged,
|
||||
unstaged,
|
||||
untracked,
|
||||
};
|
||||
export async function captureBaseline(repoRoot: string): Promise<WorktreeBaseline> {
|
||||
const [root, nestedPaths] = await Promise.all([captureRepoBaseline(repoRoot), discoverNestedRepos(repoRoot)]);
|
||||
const nested = await Promise.all(
|
||||
nestedPaths.map(async relativePath => ({
|
||||
relativePath,
|
||||
baseline: await captureRepoBaseline(path.join(repoRoot, relativePath)),
|
||||
})),
|
||||
);
|
||||
return { root, nested };
|
||||
}
|
||||
|
||||
async function writeTempPatchFile(patch: string): Promise<string> {
|
||||
@@ -81,13 +143,13 @@ async function applyPatch(
|
||||
}
|
||||
}
|
||||
|
||||
export async function applyBaseline(worktreeDir: string, baseline: WorktreeBaseline): Promise<void> {
|
||||
await applyPatch(worktreeDir, baseline.staged, { cached: true });
|
||||
await applyPatch(worktreeDir, baseline.staged);
|
||||
await applyPatch(worktreeDir, baseline.unstaged);
|
||||
async function applyRepoBaseline(worktreeDir: string, rb: RepoBaseline, sourceRoot: string): Promise<void> {
|
||||
await applyPatch(worktreeDir, rb.staged, { cached: true });
|
||||
await applyPatch(worktreeDir, rb.staged);
|
||||
await applyPatch(worktreeDir, rb.unstaged);
|
||||
|
||||
for (const entry of baseline.untracked) {
|
||||
const source = path.join(baseline.repoRoot, entry);
|
||||
for (const entry of rb.untracked) {
|
||||
const source = path.join(sourceRoot, entry);
|
||||
const destination = path.join(worktreeDir, entry);
|
||||
try {
|
||||
await fs.mkdir(path.dirname(destination), { recursive: true });
|
||||
@@ -99,6 +161,25 @@ export async function applyBaseline(worktreeDir: string, baseline: WorktreeBasel
|
||||
}
|
||||
}
|
||||
|
||||
export async function applyBaseline(worktreeDir: string, baseline: WorktreeBaseline): Promise<void> {
|
||||
await applyRepoBaseline(worktreeDir, baseline.root, baseline.root.repoRoot);
|
||||
|
||||
// Restore nested repos into the worktree
|
||||
for (const { relativePath, baseline: nb } of baseline.nested) {
|
||||
const nestedDir = path.join(worktreeDir, relativePath);
|
||||
// Copy the nested repo wholesale (it's not managed by root git)
|
||||
const sourceDir = path.join(baseline.root.repoRoot, relativePath);
|
||||
try {
|
||||
await fs.cp(sourceDir, nestedDir, { recursive: true });
|
||||
} catch (err) {
|
||||
if (isEnoent(err)) continue;
|
||||
throw err;
|
||||
}
|
||||
// Then apply any uncommitted changes from the nested baseline
|
||||
await applyRepoBaseline(nestedDir, nb, nb.repoRoot);
|
||||
}
|
||||
}
|
||||
|
||||
async function applyPatchToIndex(cwd: string, patch: string, indexFile: string): Promise<void> {
|
||||
if (!patch.trim()) return;
|
||||
const tempPath = await writeTempPatchFile(patch);
|
||||
@@ -122,31 +203,23 @@ async function listUntracked(cwd: string): Promise<string[]> {
|
||||
.filter(line => line.length > 0);
|
||||
}
|
||||
|
||||
export async function captureDeltaPatch(worktreeDir: string, baseline: WorktreeBaseline): Promise<string> {
|
||||
async function captureRepoDeltaPatch(repoDir: string, rb: RepoBaseline): Promise<string> {
|
||||
const tempIndex = path.join(os.tmpdir(), `omp-task-index-${Snowflake.next()}`);
|
||||
try {
|
||||
await $`git read-tree HEAD`.cwd(worktreeDir).env({
|
||||
GIT_INDEX_FILE: tempIndex,
|
||||
});
|
||||
await applyPatchToIndex(worktreeDir, baseline.staged, tempIndex);
|
||||
await applyPatchToIndex(worktreeDir, baseline.unstaged, tempIndex);
|
||||
const diff = await $`git diff --binary`
|
||||
.cwd(worktreeDir)
|
||||
.env({
|
||||
GIT_INDEX_FILE: tempIndex,
|
||||
})
|
||||
.quiet()
|
||||
.text();
|
||||
await $`git read-tree HEAD`.cwd(repoDir).env({ GIT_INDEX_FILE: tempIndex });
|
||||
await applyPatchToIndex(repoDir, rb.staged, tempIndex);
|
||||
await applyPatchToIndex(repoDir, rb.unstaged, tempIndex);
|
||||
const diff = await $`git diff --binary`.cwd(repoDir).env({ GIT_INDEX_FILE: tempIndex }).quiet().text();
|
||||
|
||||
const currentUntracked = await listUntracked(worktreeDir);
|
||||
const baselineUntracked = new Set(baseline.untracked);
|
||||
const currentUntracked = await listUntracked(repoDir);
|
||||
const baselineUntracked = new Set(rb.untracked);
|
||||
const newUntracked = currentUntracked.filter(entry => !baselineUntracked.has(entry));
|
||||
|
||||
if (newUntracked.length === 0) return diff;
|
||||
|
||||
const untrackedDiffs = await Promise.all(
|
||||
newUntracked.map(entry =>
|
||||
$`git diff --binary --no-index /dev/null ${entry}`.cwd(worktreeDir).quiet().nothrow().text(),
|
||||
$`git diff --binary --no-index /dev/null ${entry}`.cwd(repoDir).quiet().nothrow().text(),
|
||||
),
|
||||
);
|
||||
return `${diff}${diff && !diff.endsWith("\n") ? "\n" : ""}${untrackedDiffs.join("\n")}`;
|
||||
@@ -155,6 +228,32 @@ export async function captureDeltaPatch(worktreeDir: string, baseline: WorktreeB
|
||||
}
|
||||
}
|
||||
|
||||
/** Rewrite a/b paths in a unified diff to be prefixed with a subdirectory. */
|
||||
function prefixPatchPaths(patch: string, prefix: string): string {
|
||||
if (!patch.trim()) return patch;
|
||||
return patch.replace(/^(---| \+\+\+) (a|b)\//gm, (_, marker, ab) => `${marker} ${ab}/${prefix}/`);
|
||||
}
|
||||
|
||||
export async function captureDeltaPatch(isolationDir: string, baseline: WorktreeBaseline): Promise<string> {
|
||||
const rootPatch = await captureRepoDeltaPatch(isolationDir, baseline.root);
|
||||
const parts = [rootPatch];
|
||||
|
||||
for (const { relativePath, baseline: nb } of baseline.nested) {
|
||||
const nestedDir = path.join(isolationDir, relativePath);
|
||||
try {
|
||||
await fs.access(path.join(nestedDir, ".git"));
|
||||
} catch {
|
||||
continue; // nested repo doesn't exist in isolation dir
|
||||
}
|
||||
const nestedPatch = await captureRepoDeltaPatch(nestedDir, nb);
|
||||
if (nestedPatch.trim()) {
|
||||
parts.push(prefixPatchPaths(nestedPatch, relativePath));
|
||||
}
|
||||
}
|
||||
|
||||
return parts.filter(p => p.trim()).join("\n");
|
||||
}
|
||||
|
||||
export async function cleanupWorktree(dir: string): Promise<void> {
|
||||
try {
|
||||
const commonDirRaw = await $`git rev-parse --git-common-dir`.cwd(dir).quiet().nothrow().text();
|
||||
@@ -224,3 +323,122 @@ export async function cleanupFuseOverlay(mergedDir: string): Promise<void> {
|
||||
await fs.rm(baseDir, { recursive: true, force: true });
|
||||
}
|
||||
}
|
||||
|
||||
// ═══════════════════════════════════════════════════════════════════════════
|
||||
// Branch-mode isolation
|
||||
// ═══════════════════════════════════════════════════════════════════════════
|
||||
|
||||
/**
|
||||
* Commit task-only changes to a new branch.
|
||||
* Uses captureDeltaPatch to isolate the task's changes from the baseline,
|
||||
* then applies that patch on a clean branch from HEAD.
|
||||
* Returns the branch name, or null if no changes to commit.
|
||||
*/
|
||||
export async function commitToBranch(
|
||||
isolationDir: string,
|
||||
baseline: WorktreeBaseline,
|
||||
taskId: string,
|
||||
description: string | undefined,
|
||||
): Promise<string | null> {
|
||||
// Capture root patch and nested patches separately
|
||||
const rootPatch = await captureRepoDeltaPatch(isolationDir, baseline.root);
|
||||
const nestedChanges: Array<{ relativePath: string; patch: string }> = [];
|
||||
for (const { relativePath, baseline: nb } of baseline.nested) {
|
||||
const nestedDir = path.join(isolationDir, relativePath);
|
||||
try {
|
||||
await fs.access(path.join(nestedDir, ".git"));
|
||||
} catch {
|
||||
continue;
|
||||
}
|
||||
const np = await captureRepoDeltaPatch(nestedDir, nb);
|
||||
if (np.trim()) nestedChanges.push({ relativePath, patch: np });
|
||||
}
|
||||
|
||||
const hasChanges = rootPatch.trim() || nestedChanges.length > 0;
|
||||
if (!hasChanges) return null;
|
||||
|
||||
const repoRoot = baseline.root.repoRoot;
|
||||
const branchName = `omp/task/${taskId}`;
|
||||
const commitMessage = description || taskId;
|
||||
|
||||
await $`git branch ${branchName} HEAD`.cwd(repoRoot).quiet();
|
||||
|
||||
const tmpDir = path.join(os.tmpdir(), `omp-branch-${Snowflake.next()}`);
|
||||
try {
|
||||
await $`git worktree add ${tmpDir} ${branchName}`.cwd(repoRoot).quiet();
|
||||
|
||||
// Apply root repo patch via git apply
|
||||
if (rootPatch.trim()) {
|
||||
const patchPath = path.join(os.tmpdir(), `omp-branch-patch-${Snowflake.next()}.patch`);
|
||||
try {
|
||||
await Bun.write(patchPath, rootPatch);
|
||||
await $`git apply --binary ${patchPath}`.cwd(tmpDir).quiet();
|
||||
} finally {
|
||||
await fs.rm(patchPath, { force: true });
|
||||
}
|
||||
}
|
||||
|
||||
// Copy nested repo changes directly (they aren't tracked by root git)
|
||||
for (const { relativePath } of nestedChanges) {
|
||||
const nestedSrc = path.join(isolationDir, relativePath);
|
||||
const nestedDst = path.join(tmpDir, relativePath);
|
||||
await fs.cp(nestedSrc, nestedDst, { recursive: true });
|
||||
}
|
||||
|
||||
await $`git add -A`.cwd(tmpDir).quiet();
|
||||
await $`git -c user.name=omp -c user.email=omp@task commit -m ${commitMessage}`.cwd(tmpDir).quiet();
|
||||
} finally {
|
||||
await $`git worktree remove -f ${tmpDir}`.cwd(repoRoot).quiet().nothrow();
|
||||
await fs.rm(tmpDir, { recursive: true, force: true });
|
||||
}
|
||||
|
||||
return branchName;
|
||||
}
|
||||
|
||||
export interface MergeBranchResult {
|
||||
merged: string[];
|
||||
failed: string[];
|
||||
conflict?: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* Merge task branches sequentially into the working tree.
|
||||
* Each branch gets a --no-ff merge commit preserving the task identity.
|
||||
* Stops on first conflict and reports which branches succeeded.
|
||||
*/
|
||||
export async function mergeTaskBranches(
|
||||
repoRoot: string,
|
||||
branches: Array<{ branchName: string; taskId: string; description?: string }>,
|
||||
): Promise<MergeBranchResult> {
|
||||
const merged: string[] = [];
|
||||
const failed: string[] = [];
|
||||
|
||||
for (const { branchName, taskId, description } of branches) {
|
||||
const mergeMessage = description || taskId;
|
||||
|
||||
const result = await $`git merge --no-ff -m ${mergeMessage} ${branchName}`.cwd(repoRoot).quiet().nothrow();
|
||||
|
||||
if (result.exitCode !== 0) {
|
||||
// Abort the failed merge to restore clean state
|
||||
await $`git merge --abort`.cwd(repoRoot).quiet().nothrow();
|
||||
const stderr = result.stderr.toString().trim();
|
||||
failed.push(branchName);
|
||||
return {
|
||||
merged,
|
||||
failed: [...failed, ...branches.slice(merged.length + failed.length).map(b => b.branchName)],
|
||||
conflict: `${branchName}: ${stderr}`,
|
||||
};
|
||||
}
|
||||
|
||||
merged.push(branchName);
|
||||
}
|
||||
|
||||
return { merged, failed };
|
||||
}
|
||||
|
||||
/** Clean up temporary task branches. */
|
||||
export async function cleanupTaskBranches(repoRoot: string, branches: string[]): Promise<void> {
|
||||
for (const branch of branches) {
|
||||
await $`git branch -D ${branch}`.cwd(repoRoot).quiet().nothrow();
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user