Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
23 changes: 22 additions & 1 deletion tests/web/session-worktree.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -93,6 +93,15 @@ function fixture(t: TestContext) {
return { root, repo };
}

async function createWithUmask(cwd: string, commandId: string, umask: number) {
const originalUmask = process.umask(umask);
try {
return await createSessionWorktree(cwd, commandId);
} finally {
process.umask(originalUmask);
}
}

// The wrapper still executes real Git. It introduces a competing change at a
// precise process boundary, without adding production-only test hooks.
function gitBoundary(
Expand Down Expand Up @@ -134,10 +143,22 @@ test("forks source HEAD detached and copies final tracked, untracked, binary, ex
const beforeStage = stagedIndex(repo);
const beforeStatus = git(repo, "status", "--porcelain=v1", "-z");
const head = git(repo, "rev-parse", "HEAD").trim();
const result = await createSessionWorktree(
const result = await createWithUmask(
path.join(repo, "packages", "app"),
"fork-dirty",
0o022,
);
if (process.platform !== "win32") {
const restrictive = await createWithUmask(
repo,
"fork-restrictive-umask",
0o077,
);
assert.equal(
fs.statSync(path.join(restrictive.worktree.path, "run.sh")).mode & 0o111,
0o111,
);
}
assert.equal(result.cwd, path.join(result.worktree.path, "packages", "app"));
// Native realpath expands Windows 8.3 aliases such as RUNNER~1.
assert.equal(result.worktree.sourceRoot, fs.realpathSync.native(repo));
Expand Down
37 changes: 29 additions & 8 deletions web/runtime/session-worktree.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@ import { execFile } from "node:child_process";
import { createHash, randomUUID } from "node:crypto";
import { constants } from "node:fs";
import {
chmod,
lstat,
mkdir,
mkdtemp,
Expand Down Expand Up @@ -522,13 +523,19 @@ async function snapshot(
environment,
);
}
validateIndex(
await git(
source.sourceRoot,
["ls-files", "--stage", "-z"],
context,
environment,
),
const entries = await git(
source.sourceRoot,
["ls-files", "--stage", "-z"],
context,
environment,
);
validateIndex(entries);
const executableFiles = paths(
entries
.split("\0")
.filter((entry) => entry.startsWith("100755 "))
.map((entry) => entry.slice(entry.indexOf("\t") + 1))
.join("\0"),
);
const tree = (
await git(source.sourceRoot, ["write-tree"], context, environment)
Expand All @@ -549,7 +556,7 @@ async function snapshot(
environment,
);
const ignored = await includedIgnored(source.sourceRoot, context);
return { tree, patch, ignored, ignoredTracked: existing };
return { tree, patch, ignored, ignoredTracked: existing, executableFiles };
} finally {
await rm(index, { force: true });
await rm(`${index}.lock`, { force: true });
Expand Down Expand Up @@ -737,6 +744,20 @@ export async function createSessionWorktree(
mutationContext,
);
}
if (process.platform !== "win32") {
for (const file of frozen.executableFiles) {
check(mutationContext);
const target = join(worktreePath, file);
const info = await lstat(target);
if (!info.isFile())
throw new SessionWorktreeError(
"changed",
"A tracked executable snapshot file changed during the fork.",
);
const mode = info.mode & 0o777;
if ((mode & 0o111) !== 0o111) await chmod(target, mode | 0o111);
}
}
for (const file of frozen.ignored) {
await copyIncludedFile(
source.sourceRoot,
Expand Down
Loading