diff --git a/.changeset/checkout-exec-bit.md b/.changeset/checkout-exec-bit.md new file mode 100644 index 00000000..b5a3ffed --- /dev/null +++ b/.changeset/checkout-exec-bit.md @@ -0,0 +1,5 @@ +--- +"just-git": patch +--- + +Checkout applies the executable bit of `100755` entries through the new optional `FileSystem.chmod`, and `status`/`diff` report mode-only changes (`old mode 100755` / `new mode 100644`) instead of silently committing them. diff --git a/docs/FILE_REFERENCE.md b/docs/FILE_REFERENCE.md index 3811d1df..7f6a9e34 100644 --- a/docs/FILE_REFERENCE.md +++ b/docs/FILE_REFERENCE.md @@ -97,7 +97,7 @@ Exported symbols from `src/lib/`. Generated by `bun scripts/gen-lib-reference.ts ### lib/index.ts -- `getIndexTimestamp`, `readIndex`, `writeIndex`, `addEntry`, `removeEntry`, `findEntry`, `hasConflicts`, `getConflictedPaths`, `getStage0Entries`, `clearIndex`, `buildIndex`, `defaultStat`, `indexStatFromFileStat`, `indexStatMatchesFile`, `gitModeFromFileStat`, `refreshIndexStatsAfterCheckout` +- `getIndexTimestamp`, `readIndex`, `writeIndex`, `addEntry`, `removeEntry`, `findEntry`, `hasConflicts`, `getConflictedPaths`, `getStage0Entries`, `clearIndex`, `buildIndex`, `defaultStat`, `indexStatFromFileStat`, `indexStatMatchesFile`, `gitModeFromFileStat`, `worktreeMode`, `refreshIndexStatsAfterCheckout` ### lib/log-format.ts @@ -250,7 +250,7 @@ Exported symbols from `src/lib/`. Generated by `bun scripts/gen-lib-reference.ts ### lib/symlink.ts -- `lstatSafe`, `isSymlinkMode`, `isSubmoduleMode`, `readWorktreeContent` +- `lstatSafe`, `isSymlinkMode`, `isExecutableMode`, `isRegularFileMode`, `isSubmoduleMode`, `readWorktreeContent` ### lib/tree-ops.ts diff --git a/src/commands/diff.ts b/src/commands/diff.ts index f2e7ffce..169b6f62 100644 --- a/src/commands/diff.ts +++ b/src/commands/diff.ts @@ -229,15 +229,13 @@ async function collectUnstaged( const indexEntry = stage0.get(diff.path); if (!indexEntry) continue; - const oldMode = fmtMode(indexEntry.mode); - items.push({ path: diff.path, status: diff.status === "deleted" ? "D" : "M", oldHash: indexEntry.hash, newHash: diff.worktreeHash, - oldMode, - newMode: oldMode, + oldMode: fmtMode(indexEntry.mode), + newMode: fmtMode(diff.worktreeMode ?? indexEntry.mode), newFromWorkTree: diff.status === "modified", }); } @@ -390,7 +388,7 @@ async function collectCached( newHash: entry.hash, newMode: fmtMode(entry.mode), }); - } else if (baseEntry.hash !== entry.hash) { + } else if (baseEntry.hash !== entry.hash || Number.parseInt(baseEntry.mode, 8) !== entry.mode) { diffs.push({ path, status: "modified", @@ -585,7 +583,7 @@ async function collectCommitToWorkTree( const effectiveHash = workTreeDiff?.status === "modified" ? workTreeDiff.worktreeHash : indexEntry.hash; - const effectiveMode = fmtMode(indexEntry.mode); + const effectiveMode = fmtMode(workTreeDiff?.worktreeMode ?? indexEntry.mode); if (effectiveHash !== entry.hash || effectiveMode !== entry.mode) { items.push({ path, diff --git a/src/commands/mv.ts b/src/commands/mv.ts index 419d9b95..887b4da1 100644 --- a/src/commands/mv.ts +++ b/src/commands/mv.ts @@ -200,7 +200,9 @@ export function registerMvCommand(parent: Command, ext?: GitExtensions) { } const content = await ctx.fs.readFileBuffer(move.srcAbs); + const srcMode = (await ctx.fs.stat(move.srcAbs)).mode; await ctx.fs.writeFile(move.dstAbs, content); + if (ctx.fs.chmod) await ctx.fs.chmod(move.dstAbs, srcMode & 0o7777); await ctx.fs.rm(move.srcAbs); // 2. Update the index: remove old entry, add new entry diff --git a/src/fs.ts b/src/fs.ts index 80992a97..3a93a789 100644 --- a/src/fs.ts +++ b/src/fs.ts @@ -38,7 +38,9 @@ export interface FileStat { * * The three optional symlink methods (`lstat`, `readlink`, `symlink`) enable * symlink support. When omitted, symlinks degrade to plain files containing - * the target path as content (`core.symlinks=false` behavior). + * the target path as content (`core.symlinks=false` behavior). The optional + * `chmod` lets checkout apply the executable bit of `100755` entries; when + * omitted, every file is checked out non-executable. * * See {@link MemoryFileSystem} for a ready-made in-memory implementation. */ @@ -98,6 +100,13 @@ export interface FileSystem { * @throws Error if `path` already exists. */ symlink?(target: string, path: string): Promise; + /** + * Set the permission bits of a file (e.g. `0o755`), following symlinks. + * Checkout uses this to apply or clear the executable bit; without it, + * a `100755` blob is checked out as `100644`. + * @throws Error if the path doesn't exist. + */ + chmod?(path: string, mode: number): Promise; /** * Move/rename a file or directory from `src` to `dest`. * diff --git a/src/lib/command-utils.ts b/src/lib/command-utils.ts index 1b5c3ef0..9aba35d2 100644 --- a/src/lib/command-utils.ts +++ b/src/lib/command-utils.ts @@ -214,14 +214,18 @@ export async function requireAuthor( * Compares stage-0 index entries against the tree map for modifications, * additions, and deletions. */ -export function hasStagedChanges(index: Index, headMap: Map): boolean { - const stage0 = new Map(); +export function hasStagedChanges( + index: Index, + headMap: Map, +): boolean { + const stage0 = new Map(); for (const e of index.entries) { if (e.stage === 0) stage0.set(e.path, e); } for (const [path, entry] of stage0) { const headEntry = headMap.get(path); if (!headEntry || headEntry.hash !== entry.hash) return true; + if (Number.parseInt(headEntry.mode, 8) !== entry.mode) return true; } for (const [path] of headMap) { if (!stage0.has(path)) return true; diff --git a/src/lib/diff-algorithm.ts b/src/lib/diff-algorithm.ts index 94d3d446..e31a3737 100644 --- a/src/lib/diff-algorithm.ts +++ b/src/lib/diff-algorithm.ts @@ -1169,6 +1169,10 @@ function formatHashForIndexLine(hash?: string): string { return abbreviateHash(hash); } +function hasModeChange(opts: FormatOptions): boolean { + return !!opts.oldMode && !!opts.newMode && opts.oldMode !== opts.newMode; +} + function pushDiffGitHeader( out: string[], opts: FormatOptions, @@ -1199,7 +1203,7 @@ function formatBinaryDiff(opts: FormatOptions, oldIsBinary: boolean, newIsBinary const newPath = opts.renameTo ?? path; const isNew = opts.isNew ?? oldContent === ""; const isDeleted = opts.isDeleted ?? newContent === ""; - if (oldContent === newContent && !isRename) return ""; + if (oldContent === newContent && !isRename && !hasModeChange(opts)) return ""; const out: string[] = []; pushDiffGitHeader(out, opts, newPath, isNew, isDeleted, isRename); @@ -1281,7 +1285,7 @@ export function formatUnifiedDiff(opts: FormatOptions): string { } const hunks = buildHunks(edits, opts.contextLines); - if (hunks.length === 0 && !isRename) return ""; + if (hunks.length === 0 && !isRename && !hasModeChange(opts)) return ""; const isNew = opts.isNew ?? oldContent === ""; const isDeleted = opts.isDeleted ?? newContent === ""; @@ -1289,7 +1293,6 @@ export function formatUnifiedDiff(opts: FormatOptions): string { const out: string[] = []; pushDiffGitHeader(out, opts, newPath, isNew, isDeleted, isRename); - // For exact renames with no content change, stop here if (hunks.length === 0) { return `${out.join("\n")}\n`; } diff --git a/src/lib/index.ts b/src/lib/index.ts index cbbd874f..94dbc676 100644 --- a/src/lib/index.ts +++ b/src/lib/index.ts @@ -1,9 +1,9 @@ -import type { FileStat } from "../fs.ts"; +import type { FileStat, FileSystem } from "../fs.ts"; import { bytesToHex, hexToBytes } from "./hex.ts"; import { verifyPath } from "./path-safety.ts"; import { join } from "./path.ts"; import { sha1 } from "./sha1.ts"; -import { isSubmoduleMode, lstatSafe } from "./symlink.ts"; +import { isRegularFileMode, isSubmoduleMode, isSymlinkMode, lstatSafe } from "./symlink.ts"; import type { GitContext, Index, IndexEntry, IndexStat } from "./types.ts"; // ── Constants ─────────────────────────────────────────────────────── @@ -192,13 +192,14 @@ export function indexStatFromFileStat(stat: FileStat, size: number = stat.size): * and must still be hashed. */ export function indexStatMatchesFile( + fs: FileSystem, entry: IndexEntry, stat: FileStat, indexTimestamp: Date | undefined, ): boolean { if (!indexTimestamp || !stat.isFile || stat.isSymbolicLink) return false; if (entry.stat.mtimeSeconds === 0 || stat.size < 0 || stat.size > 0xffffffff) return false; - if (gitModeFromFileStat(stat) !== entry.mode || stat.size !== entry.stat.size) return false; + if (worktreeMode(fs, entry, stat) !== entry.mode || stat.size !== entry.stat.size) return false; const fileMtimeMs = timestampMilliseconds(stat.mtime); const indexMtimeMs = timestampMilliseconds(indexTimestamp); @@ -231,6 +232,30 @@ export function gitModeFromFileStat(stat: FileStat): number { return stat.mode & 0o111 ? 0o100755 : 0o100644; } +function fileStandsInForSymlink( + fs: FileSystem, + entry: Pick, + stat: FileStat, +): boolean { + return stat.isFile && isSymlinkMode(entry.mode) && !fs.symlink; +} + +function fsTracksExecutableBit(fs: FileSystem): boolean { + return fs.chmod !== undefined; +} + +export function worktreeMode( + fs: FileSystem, + entry: Pick | undefined, + stat: FileStat, +): number { + if (entry && fileStandsInForSymlink(fs, entry, stat)) return entry.mode; + if (stat.isFile && !fsTracksExecutableBit(fs)) { + return entry && isRegularFileMode(entry.mode) ? entry.mode : 0o100644; + } + return gitModeFromFileStat(stat); +} + /** * Refresh stat metadata for index entries whose content a caller has just * materialized in the worktree. Paths that cannot be verified by type/mode @@ -256,7 +281,7 @@ export async function refreshIndexStatsAfterCheckout( } const stat = await lstatSafe(ctx.fs, join(ctx.workTree, entry.path)).catch(() => null); - if (!stat || gitModeFromFileStat(stat) !== entry.mode) { + if (!stat || worktreeMode(ctx.fs, entry, stat) !== entry.mode) { entries.push(entry); continue; } diff --git a/src/lib/status-format.ts b/src/lib/status-format.ts index e484f231..3376ffeb 100644 --- a/src/lib/status-format.ts +++ b/src/lib/status-format.ts @@ -452,7 +452,7 @@ export async function getStagedChanges( newHash: entry.hash, newMode: entry.mode.toString(8).padStart(6, "0"), }); - } else if (headEntry.hash !== entry.hash) { + } else if (headEntry.hash !== entry.hash || Number.parseInt(headEntry.mode, 8) !== entry.mode) { rawDiffs.push({ path, status: "modified", diff --git a/src/lib/symlink.ts b/src/lib/symlink.ts index fcc54f76..3045b5e4 100644 --- a/src/lib/symlink.ts +++ b/src/lib/symlink.ts @@ -13,6 +13,16 @@ export function isSymlinkMode(mode: number | string): boolean { return mode === 0o120000; } +export function isExecutableMode(mode: number | string): boolean { + if (typeof mode === "string") return mode === "100755"; + return mode === 0o100755; +} + +export function isRegularFileMode(mode: number | string): boolean { + if (typeof mode === "string") return mode === "100644" || mode === "100755"; + return mode === 0o100644 || mode === 0o100755; +} + /** Check whether a git mode (numeric or string) represents a submodule (gitlink). */ export function isSubmoduleMode(mode: number | string): boolean { if (typeof mode === "string") return mode === "160000"; diff --git a/src/lib/types.ts b/src/lib/types.ts index 6f49209d..65a8e104 100644 --- a/src/lib/types.ts +++ b/src/lib/types.ts @@ -302,4 +302,5 @@ export interface WorkTreeDiff { indexHash?: ObjectId; /** Hash computed from modified worktree content during comparison. */ worktreeHash?: ObjectId; + worktreeMode?: number; } diff --git a/src/lib/unpack-trees.ts b/src/lib/unpack-trees.ts index 2853c20a..8b92bf3c 100644 --- a/src/lib/unpack-trees.ts +++ b/src/lib/unpack-trees.ts @@ -18,12 +18,13 @@ */ import { comparePaths, err } from "./command-utils.ts"; -import { defaultStat, getStage0Entries } from "./index.ts"; +import { defaultStat, getStage0Entries, worktreeMode } from "./index.ts"; import { isInsideWorkTree, verifyPath } from "./path-safety.ts"; import { dirname, join } from "./path.ts"; import { hashCleanedWorktreeEntry } from "./eol.ts"; import { lstatSafe } from "./symlink.ts"; import { flattenTreeToMap } from "./tree-ops.ts"; +import type { FileStat } from "../fs.ts"; import type { GitContext, Index, IndexEntry, ObjectId } from "./types.ts"; import { checkoutEntry, cleanEmptyDirs, walkWorkTree } from "./worktree.ts"; @@ -81,6 +82,8 @@ interface PathState { /** Hash currently in the index (stage 0), or null if not in index. */ indexHash: ObjectId | null; + indexMode: number | null; + /** Stage of the current index entry (0 for normal, >0 for conflict). */ indexStage: number; @@ -100,6 +103,8 @@ interface PathState { */ getWorktreeHash: () => Promise; + getWorktreeMode: () => Promise; + /** Mode from the head tree (for entry creation). */ headMode: string | null; @@ -364,6 +369,7 @@ async function buildPathStates( const indexEntry = stage0Map.get(path); const indexHash = indexEntry?.hash ?? null; + const indexMode = indexEntry?.mode ?? null; const indexStage = conflictPaths.has(path) ? 1 : 0; const existsOnDisk = worktreeFiles.has(path); @@ -380,32 +386,50 @@ async function buildPathStates( return cachedIgnored; }; + let cachedStat: FileStat | null | undefined; + const getWorktreeStat = async (): Promise => { + if (cachedStat !== undefined) return cachedStat; + cachedStat = + existsOnDisk && ctx.workTree + ? await lstatSafe(ctx.fs, join(ctx.workTree, path)).catch(() => null) + : null; + return cachedStat; + }; + let cachedHash: ObjectId | null | undefined; const getWorktreeHash = async (): Promise => { if (cachedHash !== undefined) return cachedHash; - if (!existsOnDisk || !ctx.workTree) { + const st = await getWorktreeStat(); + if (!st || !ctx.workTree) { cachedHash = null; return null; } const fullPath = join(ctx.workTree, path); try { - cachedHash = await hashCleanedWorktreeEntry(ctx, fullPath, indexHash ?? undefined); + cachedHash = await hashCleanedWorktreeEntry(ctx, fullPath, indexHash ?? undefined, st); } catch { cachedHash = null; } return cachedHash; }; + const getWorktreeMode = async (): Promise => { + const st = await getWorktreeStat(); + return st ? worktreeMode(ctx.fs, indexEntry, st) : null; + }; + states.push({ path, baseHash, headHash, remoteHash, indexHash, + indexMode, indexStage, existsOnDisk, isIgnoredOnDisk, getWorktreeHash, + getWorktreeMode, headMode, remoteMode, }); @@ -418,6 +442,21 @@ async function buildPathStates( // SECTION 7: One-Way Merge // ===================================================================== +function same( + hashA: ObjectId | null, + modeA: string | number | null, + hashB: ObjectId | null, + modeB: string | number | null, +): boolean { + if (hashA !== hashB) return false; + if (hashA === null) return true; + return modeNumber(modeA) === modeNumber(modeB); +} + +function modeNumber(mode: string | number | null): number | null { + return typeof mode === "string" ? Number.parseInt(mode, 8) : mode; +} + /** * One-way merge: replace the index with a target tree. * Used by: git reset --hard, git merge --abort @@ -447,7 +486,7 @@ export function onewayMerge(state: PathState, opts: UnpackOptions): MergeDecisio } // Target present, index matches: keep (preserves stat info) - if (state.indexHash === target) { + if (same(state.indexHash, state.indexMode, target, state.remoteMode)) { return { action: MergeAction.KEEP, requirements: [], @@ -497,10 +536,14 @@ export function onewayMerge(state: PathState, opts: UnpackOptions): MergeDecisio */ export function twowayMerge(state: PathState, opts: UnpackOptions): MergeDecision { const { headHash: old, remoteHash: nu, indexHash: idx } = state; + const { headMode: oldMode, remoteMode: nuMode, indexMode: idxMode } = state; + const sameOldNew = same(old, oldMode, nu, nuMode); + const sameIdxOld = same(idx, idxMode, old, oldMode); + const sameIdxNew = same(idx, idxMode, nu, nuMode); // Handle conflicted index entry (stages > 0) if (state.indexStage > 0) { - if (old === nu) { + if (sameOldNew) { // Trees agree — resolve conflict if (nu === null) { return { action: MergeAction.DELETE, requirements: [] }; @@ -542,7 +585,7 @@ export function twowayMerge(state: PathState, opts: UnpackOptions): MergeDecisio if (old !== null) { // Staged deletion: file was in old tree but removed from index. // git: if (oldtree && !o->initial_checkout) { ... } - if (old === nu) { + if (sameOldNew) { // Old == new: trees unchanged, preserve staged deletion return { action: MergeAction.SKIP, @@ -584,12 +627,12 @@ export function twowayMerge(state: PathState, opts: UnpackOptions): MergeDecisio } // Cases 6/7: old absent, new matches index → keep index - if (old === null && nu === idx) { + if (old === null && sameIdxNew) { return { action: MergeAction.KEEP, caseNumber: 6, requirements: [] }; } // Case 10: old matches index, new absent → delete - if (old === idx && nu === null) { + if (sameIdxOld && nu === null) { return { action: MergeAction.DELETE, caseNumber: 10, @@ -598,17 +641,17 @@ export function twowayMerge(state: PathState, opts: UnpackOptions): MergeDecisio } // Cases 14/15: old==new (trees unchanged) → keep index - if (old !== null && old === nu) { + if (old !== null && sameOldNew) { return { action: MergeAction.KEEP, caseNumber: 14, requirements: [] }; } // Cases 18/19: index already matches new → keep index - if (old !== null && nu !== null && idx === nu) { + if (old !== null && nu !== null && sameIdxNew) { return { action: MergeAction.KEEP, caseNumber: 18, requirements: [] }; } // Case 20: index matches old, new differs → take new - if (old !== null && nu !== null && idx === old && idx !== nu) { + if (old !== null && nu !== null && sameIdxOld && !sameIdxNew) { return { action: MergeAction.TAKE, takeFrom: "remote", @@ -759,6 +802,9 @@ export async function checkSingleRequirement( } return UnpackError.NOT_UPTODATE_FILE; } + if (wtHash !== null && (await state.getWorktreeMode()) !== state.indexMode) { + return UnpackError.NOT_UPTODATE_FILE; + } return null; } diff --git a/src/lib/worktree.ts b/src/lib/worktree.ts index 315eb927..297e4da5 100644 --- a/src/lib/worktree.ts +++ b/src/lib/worktree.ts @@ -5,14 +5,14 @@ import { addEntry, findEntry, getIndexTimestamp, - gitModeFromFileStat, indexStatFromFileStat, indexStatMatchesFile, + worktreeMode, } from "./index.ts"; import { readObject, writeObject } from "./object-db.ts"; import { isInsideWorkTree, verifyPath, verifySymlinkTarget } from "./path-safety.ts"; import { dirname, join } from "./path.ts"; -import { isSubmoduleMode, isSymlinkMode, lstatSafe } from "./symlink.ts"; +import { isExecutableMode, isSubmoduleMode, isSymlinkMode, lstatSafe } from "./symlink.ts"; import { flattenTree } from "./tree-ops.ts"; import type { GitContext, Index, IndexEntry, ObjectId, WorkTreeDiff } from "./types.ts"; @@ -86,16 +86,18 @@ export async function diffIndexToWorkTree( continue; } - if (indexStatMatchesFile(entry, st, indexTimestamp)) continue; + if (indexStatMatchesFile(ctx.fs, entry, st, indexTimestamp)) continue; + const workTreeMode = worktreeMode(ctx.fs, entry, st); const workTreeHash = await hashCleanedWorktreeEntry(ctx, fullPath, entry.hash, st); - if (workTreeHash !== entry.hash) { + if (workTreeHash !== entry.hash || workTreeMode !== entry.mode) { results.push({ path: entry.path, status: "modified", indexHash: entry.hash, worktreeHash: workTreeHash, + worktreeMode: workTreeMode, }); if (stopAfterFirst) return results; } @@ -179,23 +181,31 @@ export async function checkoutEntry( } await ctx.fs.symlink(target, fullPath); } else { - // For regular files, also remove stale symlinks at the same path - // so that writeFile doesn't follow the old symlink. - if (ctx.fs.lstat) { - try { - const st = await ctx.fs.lstat(fullPath); - if (st.isSymbolicLink) { - await ctx.fs.rm(fullPath, { force: true }); - } - } catch { - // Path doesn't exist — fine + // For regular files, remove a stale symlink at the same path so that + // writeFile doesn't follow it. Otherwise remember the existing mode: + // writeFile keeps it on real filesystems, so a 100644 entry over an + // executable file must clear the bit explicitly. + let existingMode: number | null = null; + try { + const st = await lstatSafe(ctx.fs, fullPath); + if (st.isSymbolicLink) { + await ctx.fs.rm(fullPath, { force: true }); + } else { + existingMode = st.mode; } - } + } catch {} // Smudge: with core.autocrlf=true, checkout writes CRLF line endings // (lfToCrlf declines for binary or already-CR content). const policy = await getEolPolicy(ctx); const content = policy.smudgeCrlf ? lfToCrlf(raw.content) : raw.content; await ctx.fs.writeFile(fullPath, content); + if (ctx.fs.chmod) { + if (entry.mode != null && isExecutableMode(entry.mode)) { + await ctx.fs.chmod(fullPath, 0o755); + } else if (existingMode !== null && existingMode & 0o111) { + await ctx.fs.chmod(fullPath, 0o644); + } + } } } @@ -253,14 +263,14 @@ export async function stageFile( return { index: addEntry(index, entry), hash }; } + const existing = findEntry(index, path); const content = await ctx.fs.readFileBuffer(fullPath); - const blobContent = await cleanForCheckin(ctx, content, findEntry(index, path)?.hash); + const blobContent = await cleanForCheckin(ctx, content, existing?.hash); const hash = await writeObject(ctx, "blob", blobContent); - const mode = gitModeFromFileStat(st); const entry: IndexEntry = { path, - mode, + mode: worktreeMode(ctx.fs, existing, st), hash, stage: 0, // Index stat data describes the worktree file, not the cleaned blob. diff --git a/src/memory-fs.ts b/src/memory-fs.ts index 392f999d..684ac068 100644 --- a/src/memory-fs.ts +++ b/src/memory-fs.ts @@ -262,6 +262,12 @@ export class MemoryFileSystem implements FileSystem { } } + async chmod(path: string, mode: number): Promise { + const entry = this.data.get(this.resolve(path)); + if (!entry) throw new Error(`ENOENT: no such file or directory, chmod '${path}'`); + entry.mode = (entry.mode & ~0o7777) | (mode & 0o7777); + } + async readlink(path: string): Promise { const resolved = this.resolveParent(path); const entry = this.data.get(resolved); diff --git a/src/tree-backed-fs.ts b/src/tree-backed-fs.ts index 9d5e57e2..026c7fe8 100644 --- a/src/tree-backed-fs.ts +++ b/src/tree-backed-fs.ts @@ -45,6 +45,14 @@ function dirname(path: string): string { return i <= 0 ? "/" : path.slice(0, i); } +function withPermissionBits(current: number, mode: number): number { + return (current & ~0o7777) | (mode & 0o7777); +} + +function resolveLinkTarget(linkPath: string, target: string): string { + return normalize(target.startsWith("/") ? target : `${dirname(linkPath)}/${target}`); +} + function treeModeToStatMode(mode: string): number { switch (mode) { case FM.EXECUTABLE: @@ -512,6 +520,37 @@ export class TreeBackedFs implements FileSystem { } } + async chmod(path: string, mode: number): Promise { + const norm = normalize(path); + const overlayEntry = this.overlay.get(norm); + if (overlayEntry?.type === "symlink") { + return this.chmod(resolveLinkTarget(norm, overlayEntry.target), mode); + } + if (overlayEntry) { + overlayEntry.mode = withPermissionBits(overlayEntry.mode, mode); + return; + } + if (this.removals.has(norm) || !(await this.exists(norm))) { + throw new Error(`ENOENT: no such file or directory, chmod '${path}'`); + } + const st = await this.lstat(norm); + if (st.isSymbolicLink) { + return this.chmod(resolveLinkTarget(norm, await this.readlink(norm)), mode); + } + this.ensureOverlayParents(norm); + this.overlay.set( + norm, + st.isDirectory + ? { type: "directory", mode: withPermissionBits(st.mode, mode), mtime: this.epoch } + : { + type: "file", + content: await this.readFileBuffer(norm), + mode: withPermissionBits(st.mode, mode), + mtime: this.epoch, + }, + ); + } + async readlink(path: string): Promise { const norm = normalize(path); diff --git a/test/commands/add.test.ts b/test/commands/add.test.ts index c38d423a..a03e1a90 100644 --- a/test/commands/add.test.ts +++ b/test/commands/add.test.ts @@ -1,6 +1,6 @@ import { describe, expect, test } from "bun:test"; import { BASIC_REPO, EMPTY_REPO, NESTED_REPO, TEST_ENV } from "../fixtures"; -import { createTestBash, quickExec, runScenario } from "../util"; +import { createTestBash, quickExec, runScenario, setupExecBitRepo } from "../util"; describe("git add", () => { describe("outside a git repo", () => { @@ -593,3 +593,13 @@ describe("git add", () => { }); }); }); + +describe("git add: executable bit", () => { + test("add stages a mode-only change", async () => { + const bash = await setupExecBitRepo(); + await bash.exec("chmod 644 run.sh && git add run.sh"); + expect((await bash.exec("git ls-files -s")).stdout).toBe( + "100644 4163036efa65bd4a469e752267498f01ea36a55c 0\trun.sh\n", + ); + }); +}); diff --git a/test/commands/checkout.test.ts b/test/commands/checkout.test.ts index 93147d8c..9a0ec8dd 100644 --- a/test/commands/checkout.test.ts +++ b/test/commands/checkout.test.ts @@ -1,6 +1,14 @@ import { describe, expect, test } from "bun:test"; import { BASIC_REPO, EMPTY_REPO, TEST_ENV_NAMED as TEST_ENV } from "../fixtures"; -import { createTestBash, quickExec, readFile, runScenario, setupClonePair } from "../util"; +import { + createTestBash, + permissionBits, + quickExec, + readFile, + runScenario, + setupClonePair, + setupExecBitRepo, +} from "../util"; describe("git checkout", () => { describe("outside a git repo", () => { @@ -657,3 +665,24 @@ describe("git checkout", () => { }); }); }); + +describe("git checkout: executable bit", () => { + test("checkout -- restores the executable bit", async () => { + const bash = await setupExecBitRepo(); + await bash.exec("rm run.sh"); + const result = await bash.exec("git checkout -- run.sh"); + expect(result.exitCode).toBe(0); + expect(await permissionBits(bash.fs, "/repo/run.sh")).toBe(0o755); + expect((await bash.exec("git status --short")).stdout).toBe(""); + }); + + test("checkout flips the bit to match the branch", async () => { + const bash = await setupExecBitRepo(); + expect((await bash.exec("git checkout plain")).exitCode).toBe(0); + expect(await permissionBits(bash.fs, "/repo/run.sh")).toBe(0o644); + expect((await bash.exec("git status --short")).stdout).toBe(""); + expect((await bash.exec("git checkout main")).exitCode).toBe(0); + expect(await permissionBits(bash.fs, "/repo/run.sh")).toBe(0o755); + expect((await bash.exec("git status --short")).stdout).toBe(""); + }); +}); diff --git a/test/commands/clone.test.ts b/test/commands/clone.test.ts index 86ea3b78..adf5c8c3 100644 --- a/test/commands/clone.test.ts +++ b/test/commands/clone.test.ts @@ -1,6 +1,13 @@ import { describe, expect, test } from "bun:test"; import { TEST_ENV as ENV } from "../fixtures"; -import { createTestBash, isDirectory, pathExists, readFile } from "../util"; +import { + createTestBash, + isDirectory, + pathExists, + permissionBits, + readFile, + setupExecBitRepo, +} from "../util"; async function setupSource(files: Record = {}) { const bash = createTestBash({ @@ -448,3 +455,13 @@ describe("git clone", () => { }); }); }); + +describe("git clone: executable bit", () => { + test("cloned executable files are executable", async () => { + const bash = await setupExecBitRepo(); + const result = await bash.exec("git clone /repo /clone", { cwd: "/" }); + expect(result.exitCode).toBe(0); + expect(await permissionBits(bash.fs, "/clone/run.sh")).toBe(0o755); + expect((await bash.exec("git status --short", { cwd: "/clone" })).stdout).toBe(""); + }); +}); diff --git a/test/commands/commit.test.ts b/test/commands/commit.test.ts index 9dcf482d..8ea93a7c 100644 --- a/test/commands/commit.test.ts +++ b/test/commands/commit.test.ts @@ -1,6 +1,6 @@ import { describe, expect, test } from "bun:test"; import { BASIC_REPO, EMPTY_REPO, TEST_ENV_NAMED as TEST_ENV } from "../fixtures"; -import { createTestBash, quickExec, readFile, runScenario } from "../util"; +import { createTestBash, quickExec, readFile, runScenario, setupExecBitRepo } from "../util"; describe("git commit", () => { describe("outside a git repo", () => { @@ -834,3 +834,30 @@ describe("git commit", () => { }); }); }); + +describe("git commit: executable bit", () => { + test("commit after checkout records no mode change", async () => { + const bash = await setupExecBitRepo(); + await bash.exec("rm run.sh && git checkout -- run.sh && git add -A"); + const result = await bash.exec("git commit -m noop"); + expect(result.exitCode).toBe(1); + expect(result.stdout).toBe("On branch main\nnothing to commit, working tree clean\n"); + }); +}); + +describe("git commit: staged mode-only change", () => { + test("commits when only the mode differs from HEAD", async () => { + const bash = await setupExecBitRepo(); + const before = (await bash.exec("git rev-parse HEAD")).stdout; + await bash.exec("chmod 644 run.sh && git add run.sh"); + + const result = await bash.exec("git commit -m mode"); + expect(result.exitCode).toBe(0); + expect(result.stdout).toMatch(/^\[main [0-9a-f]+\] mode\n/); + expect((await bash.exec("git rev-parse HEAD")).stdout).not.toBe(before); + expect((await bash.exec("git status --short")).stdout).toBe(""); + expect((await bash.exec("git diff HEAD~1 HEAD")).stdout).toBe( + "diff --git a/run.sh b/run.sh\nold mode 100755\nnew mode 100644\n", + ); + }); +}); diff --git a/test/commands/describe.test.ts b/test/commands/describe.test.ts index 8ad32a42..87b0d5a0 100644 --- a/test/commands/describe.test.ts +++ b/test/commands/describe.test.ts @@ -1,6 +1,6 @@ import { describe, expect, test } from "bun:test"; import { EMPTY_REPO, envAt, TEST_ENV } from "../fixtures"; -import { createTestBash, quickExec, runScenario } from "../util"; +import { createTestBash, quickExec, runScenario, setupExecBitRepo } from "../util"; describe("git describe", () => { describe("basic", () => { @@ -212,6 +212,14 @@ describe("git describe", () => { expect(result.stdout.trim()).toBe("v1.0.0-dirty"); }); + test("appends -dirty when only a mode change is staged", async () => { + const bash = await setupExecBitRepo(); + await bash.exec('git tag -a v1.0.0 -m "r1"'); + await bash.exec("chmod 644 run.sh && git add run.sh"); + const result = await bash.exec("git describe --dirty"); + expect(result.stdout).toBe("v1.0.0-dirty\n"); + }); + test("no -dirty when worktree is clean", async () => { const { results } = await runScenario( [ diff --git a/test/commands/diff.test.ts b/test/commands/diff.test.ts index fdcdaeea..7666f405 100644 --- a/test/commands/diff.test.ts +++ b/test/commands/diff.test.ts @@ -1,6 +1,6 @@ import { describe, expect, test } from "bun:test"; import { BASIC_REPO, EMPTY_REPO, TEST_ENV_NAMED as TEST_ENV } from "../fixtures"; -import { createTestBash, observeFsCalls, quickExec } from "../util"; +import { createTestBash, observeFsCalls, quickExec, setupExecBitRepo } from "../util"; describe("git diff", () => { describe("outside a git repo", () => { @@ -1195,3 +1195,53 @@ describe("git diff", () => { }); }); }); + +describe("git diff: executable bit", () => { + const MODE_ONLY = "diff --git a/run.sh b/run.sh\nold mode 100755\nnew mode 100644\n"; + + test("mode-only change prints old and new mode", async () => { + const bash = await setupExecBitRepo(); + await bash.exec("chmod 644 run.sh"); + expect((await bash.exec("git diff")).stdout).toBe(MODE_ONLY); + expect((await bash.exec("git diff HEAD")).stdout).toBe(MODE_ONLY); + expect((await bash.exec("git diff --stat")).stdout).toBe( + " run.sh | 0\n 1 file changed, 0 insertions(+), 0 deletions(-)\n", + ); + await bash.exec("git add run.sh"); + expect((await bash.exec("git diff --cached")).stdout).toBe(MODE_ONLY); + expect((await bash.exec("git diff")).stdout).toBe(""); + }); + + test("content and mode change prints mode lines then an index line without mode", async () => { + const bash = await setupExecBitRepo(); + await bash.exec("chmod 644 run.sh && printf '#!/bin/sh\\necho bye\\n' > run.sh"); + expect((await bash.exec("git diff")).stdout).toBe( + [ + "diff --git a/run.sh b/run.sh", + "old mode 100755", + "new mode 100644", + "index 4163036..ac23810", + "--- a/run.sh", + "+++ b/run.sh", + "@@ -1,2 +1,2 @@", + " #!/bin/sh", + "-echo hi", + "+echo bye", + "", + ].join("\n"), + ); + }); +}); + +describe("git diff: mode-only change on a binary file", () => { + test("prints the mode lines and no binary notice", async () => { + const bash = createTestBash({ files: EMPTY_REPO, env: TEST_ENV }); + await bash.fs.writeFile("/repo/tool.bin", new Uint8Array([0x00, 0x01, 0x02])); + await bash.exec("git init && chmod 755 tool.bin && git add tool.bin && git commit -m bin"); + await bash.exec("chmod 644 tool.bin"); + + expect((await bash.exec("git diff")).stdout).toBe( + "diff --git a/tool.bin b/tool.bin\nold mode 100755\nnew mode 100644\n", + ); + }); +}); diff --git a/test/commands/file-mode.test.ts b/test/commands/file-mode.test.ts new file mode 100644 index 00000000..a3590dab --- /dev/null +++ b/test/commands/file-mode.test.ts @@ -0,0 +1,73 @@ +import { describe, expect, test } from "bun:test"; +import { createGit, MemoryFileSystem } from "../../src"; +import { TEST_ENV } from "../fixtures"; + +async function repoWithExecutable(): Promise<{ + fs: MemoryFileSystem; + run: (cmd: string, cwd: string) => Promise<{ exitCode: number; stdout: string; stderr: string }>; +}> { + const fs = new MemoryFileSystem(); + const git = createGit({ fs }); + const run = (cmd: string, cwd: string) => git.exec(cmd, { cwd, env: TEST_ENV }); + await fs.mkdir("/repo", { recursive: true }); + await fs.writeFile("/repo/run.sh", "#!/bin/sh\necho hi\n"); + await fs.chmod("/repo/run.sh", 0o755); + await run("init", "/repo"); + await run("add run.sh", "/repo"); + await run("commit -m exec", "/repo"); + return { fs, run }; +} + +describe("FileSystem without chmod behaves like core.fileMode=false", () => { + test("clone, status, commit -a and add -A keep 100755 without the exec bit on disk", async () => { + const { fs, run } = await repoWithExecutable(); + Object.defineProperty(fs, "chmod", { value: undefined }); + + expect((await run("clone /repo /clone", "/")).exitCode).toBe(0); + expect((await fs.stat("/clone/run.sh")).mode).toBe(0o100644); + expect((await run("ls-files -s", "/clone")).stdout).toMatch(/^100755 /); + expect((await run("status --short", "/clone")).stdout).toBe(""); + + const commitAll = await run("commit -a -m noop", "/clone"); + expect(commitAll.exitCode).toBe(1); + expect(commitAll.stdout).toContain("nothing to commit, working tree clean"); + + await run("add -A", "/clone"); + expect((await run("ls-files -s", "/clone")).stdout).toMatch(/^100755 /); + expect((await run("status --short", "/clone")).stdout).toBe(""); + }); + + test("a new file is added as 100644 even when stat reports an exec bit", async () => { + const { fs, run } = await repoWithExecutable(); + await fs.writeFile("/repo/new.sh", "x\n"); + await fs.chmod("/repo/new.sh", 0o755); + Object.defineProperty(fs, "chmod", { value: undefined }); + + await run("add new.sh", "/repo"); + expect((await run("ls-files -s new.sh", "/repo")).stdout).toMatch(/^100644 /); + }); +}); + +describe("FileSystem without symlinks behaves like core.symlinks=false", () => { + test("status is clean after checking out a 120000 entry as a plain file", async () => { + const fs = new MemoryFileSystem(); + const git = createGit({ fs }); + const run = (cmd: string, cwd: string) => git.exec(cmd, { cwd, env: TEST_ENV }); + await fs.mkdir("/repo", { recursive: true }); + await fs.writeFile("/repo/a.txt", "a\n"); + await fs.symlink("a.txt", "/repo/link"); + await run("init", "/repo"); + await run("add .", "/repo"); + await run("commit -m link", "/repo"); + for (const method of ["symlink", "readlink", "lstat"]) { + Object.defineProperty(fs, method, { value: undefined }); + } + + expect((await run("clone /repo /clone", "/")).exitCode).toBe(0); + expect((await fs.stat("/clone/link")).isFile).toBe(true); + expect(await fs.readFile("/clone/link")).toBe("a.txt"); + expect((await run("ls-files -s link", "/clone")).stdout).toMatch(/^120000 /); + expect((await run("status --short", "/clone")).stdout).toBe(""); + expect((await run("diff", "/clone")).stdout).toBe(""); + }); +}); diff --git a/test/commands/mv.test.ts b/test/commands/mv.test.ts index eb7f9230..2142a61c 100644 --- a/test/commands/mv.test.ts +++ b/test/commands/mv.test.ts @@ -1,6 +1,13 @@ import { describe, expect, test } from "bun:test"; import { BASIC_REPO, EMPTY_REPO, NESTED_REPO } from "../fixtures"; -import { createTestBash, pathExists, readFile, runScenario } from "../util"; +import { + createTestBash, + pathExists, + permissionBits, + readFile, + runScenario, + setupExecBitRepo, +} from "../util"; const TEST_ENV = { GIT_AUTHOR_NAME: "Test", @@ -314,3 +321,14 @@ describe("git mv", () => { }); }); }); + +describe("git mv: executable bit", () => { + test("keeps the executable bit and commits as a pure rename", async () => { + const bash = await setupExecBitRepo(); + await bash.exec("rm run.sh && git checkout -- run.sh"); + await bash.exec("git mv run.sh bin.sh"); + expect(await permissionBits(bash.fs, "/repo/bin.sh")).toBe(0o755); + const status = await bash.exec("git status --short"); + expect(status.stdout).toBe("R run.sh -> bin.sh\n"); + }); +}); diff --git a/test/commands/reset.test.ts b/test/commands/reset.test.ts index b86f750a..49043971 100644 --- a/test/commands/reset.test.ts +++ b/test/commands/reset.test.ts @@ -1,6 +1,6 @@ import { describe, expect, test } from "bun:test"; import { EMPTY_REPO, TEST_ENV_NAMED as TEST_ENV } from "../fixtures"; -import { createTestBash, readFile } from "../util"; +import { createTestBash, permissionBits, readFile, setupExecBitRepo } from "../util"; describe("git reset", () => { describe("--quiet", () => { @@ -112,3 +112,20 @@ describe("git reset", () => { }); }); }); + +describe("git reset: executable bit", () => { + test("reset --hard restores the executable bit", async () => { + const bash = await setupExecBitRepo(); + await bash.exec("chmod 644 run.sh"); + expect((await bash.exec("git reset --hard")).exitCode).toBe(0); + expect(await permissionBits(bash.fs, "/repo/run.sh")).toBe(0o755); + expect((await bash.exec("git status --short")).stdout).toBe(""); + }); + + test("reset --hard clears the executable bit", async () => { + const bash = await setupExecBitRepo(); + expect((await bash.exec("git reset --hard plain")).exitCode).toBe(0); + expect(await permissionBits(bash.fs, "/repo/run.sh")).toBe(0o644); + expect((await bash.exec("git status --short")).stdout).toBe(""); + }); +}); diff --git a/test/commands/restore.test.ts b/test/commands/restore.test.ts index 0f48a2f3..19f77d29 100644 --- a/test/commands/restore.test.ts +++ b/test/commands/restore.test.ts @@ -1,6 +1,13 @@ import { describe, expect, test } from "bun:test"; import { EMPTY_REPO, TEST_ENV_NAMED as TEST_ENV } from "../fixtures"; -import { createTestBash, quickExec, readFile, runScenario } from "../util"; +import { + createTestBash, + permissionBits, + quickExec, + readFile, + runScenario, + setupExecBitRepo, +} from "../util"; describe("git restore", () => { describe("errors", () => { @@ -325,3 +332,22 @@ describe("git restore", () => { }); }); }); + +describe("git restore: executable bit", () => { + test("restore sets the executable bit from the index", async () => { + const bash = await setupExecBitRepo(); + await bash.exec("chmod 644 run.sh"); + const result = await bash.exec("git restore run.sh"); + expect(result.exitCode).toBe(0); + expect(await permissionBits(bash.fs, "/repo/run.sh")).toBe(0o755); + expect((await bash.exec("git status --short")).stdout).toBe(""); + }); + + test("restore --source writes the source tree's mode", async () => { + const bash = await setupExecBitRepo(); + const result = await bash.exec("git restore --source plain run.sh"); + expect(result.exitCode).toBe(0); + expect(await permissionBits(bash.fs, "/repo/run.sh")).toBe(0o644); + expect((await bash.exec("git status --short")).stdout).toBe(" M run.sh\n"); + }); +}); diff --git a/test/commands/status.test.ts b/test/commands/status.test.ts index e05d3cf9..d4cc6941 100644 --- a/test/commands/status.test.ts +++ b/test/commands/status.test.ts @@ -1,6 +1,6 @@ import { afterEach, describe, expect, setSystemTime, test } from "bun:test"; import { BASIC_REPO, EMPTY_REPO, NESTED_REPO, TEST_ENV } from "../fixtures"; -import { createTestBash, observeFsCalls, quickExec, runScenario } from "../util"; +import { createTestBash, observeFsCalls, quickExec, runScenario, setupExecBitRepo } from "../util"; describe("git status", () => { describe("outside a git repo", () => { @@ -767,3 +767,25 @@ describe("git status", () => { }); }); }); + +describe("git status: executable bit", () => { + test("chmod -x on a tracked executable shows as modified", async () => { + const bash = await setupExecBitRepo(); + await bash.exec("chmod 644 run.sh"); + expect((await bash.exec("git status --short")).stdout).toBe(" M run.sh\n"); + expect((await bash.exec("git status")).stdout).toBe( + [ + "On branch main", + "Changes not staged for commit:", + ' (use "git add ..." to update what will be committed)', + ' (use "git restore ..." to discard changes in working directory)', + "\tmodified: run.sh", + "", + 'no changes added to commit (use "git add" and/or "git commit -a")', + "", + ].join("\n"), + ); + await bash.exec("git add run.sh"); + expect((await bash.exec("git status --short")).stdout).toBe("M run.sh\n"); + }); +}); diff --git a/test/commands/switch.test.ts b/test/commands/switch.test.ts index a4d7e210..ce3f9721 100644 --- a/test/commands/switch.test.ts +++ b/test/commands/switch.test.ts @@ -1,6 +1,14 @@ import { describe, expect, test } from "bun:test"; import { BASIC_REPO, EMPTY_REPO, TEST_ENV_NAMED as TEST_ENV } from "../fixtures"; -import { createTestBash, quickExec, readFile, runScenario, setupClonePair } from "../util"; +import { + createTestBash, + permissionBits, + quickExec, + readFile, + runScenario, + setupClonePair, + setupExecBitRepo, +} from "../util"; describe("git switch", () => { describe("errors", () => { @@ -478,3 +486,32 @@ describe("git switch", () => { }); }); }); + +describe("git switch: executable bit", () => { + test("switch flips the bit to match the branch", async () => { + const bash = await setupExecBitRepo(); + expect((await bash.exec("git switch plain")).exitCode).toBe(0); + expect(await permissionBits(bash.fs, "/repo/run.sh")).toBe(0o644); + expect((await bash.exec("git status --short")).stdout).toBe(""); + expect((await bash.exec("git switch main")).exitCode).toBe(0); + expect(await permissionBits(bash.fs, "/repo/run.sh")).toBe(0o755); + expect((await bash.exec("git status --short")).stdout).toBe(""); + }); + + test("switch refuses to overwrite a local mode-only change", async () => { + const bash = await setupExecBitRepo(); + await bash.exec("git switch -c content && echo more >> run.sh && git commit -qam content"); + await bash.exec("git switch -q main && chmod 644 run.sh"); + const refused = + "error: Your local changes to the following files would be overwritten by checkout:\n\trun.sh\nPlease commit your changes or stash them before you switch branches.\nAborting\n"; + + for (const target of ["plain", "content"]) { + const result = await bash.exec(`git switch ${target}`); + expect(result.exitCode).toBe(1); + expect(result.stderr).toBe(refused); + expect(await permissionBits(bash.fs, "/repo/run.sh")).toBe(0o644); + expect((await bash.exec("git status --short")).stdout).toBe(" M run.sh\n"); + expect((await bash.exec("git branch --show-current")).stdout).toBe("main\n"); + } + }); +}); diff --git a/test/lib/index.test.ts b/test/lib/index.test.ts index 6e978324..fc93f715 100644 --- a/test/lib/index.test.ts +++ b/test/lib/index.test.ts @@ -7,6 +7,9 @@ import { indexStatMatchesFile, } from "../../src/lib/index"; import type { IndexEntry } from "../../src/lib/types"; +import { MemoryFileSystem } from "../../src/memory-fs"; + +const fs = new MemoryFileSystem(); function regularStat(overrides: Partial = {}): FileStat { return { @@ -35,14 +38,14 @@ describe("index stat cache", () => { const stat = regularStat(); const entry = entryFor(stat); - expect(indexStatMatchesFile(entry, stat, new Date("2026-09-04T12:00:01Z"))).toBe(true); + expect(indexStatMatchesFile(fs, entry, stat, new Date("2026-09-04T12:00:01Z"))).toBe(true); }); test("rejects zero/default stat data", () => { const stat = regularStat(); const entry = { ...entryFor(stat), stat: defaultStat() }; - expect(indexStatMatchesFile(entry, stat, new Date("2026-09-04T12:00:01Z"))).toBe(false); + expect(indexStatMatchesFile(fs, entry, stat, new Date("2026-09-04T12:00:01Z"))).toBe(false); }); test("missing runtime timestamps degrade to hashing", () => { @@ -51,7 +54,9 @@ describe("index stat cache", () => { const withoutMtime = { ...stat, mtime: undefined } as unknown as FileStat; expect(indexStatFromFileStat(withoutMtime).mtimeSeconds).toBe(0); - expect(indexStatMatchesFile(entry, withoutMtime, new Date("2026-09-04T12:00:01Z"))).toBe(false); + expect(indexStatMatchesFile(fs, entry, withoutMtime, new Date("2026-09-04T12:00:01Z"))).toBe( + false, + ); }); test("rejects size and mode changes", () => { @@ -60,13 +65,19 @@ describe("index stat cache", () => { expect( indexStatMatchesFile( + fs, entry, { ...stat, size: stat.size + 1 }, new Date("2026-09-04T12:00:01Z"), ), ).toBe(false); expect( - indexStatMatchesFile(entry, { ...stat, mode: 0o100755 }, new Date("2026-09-04T12:00:01Z")), + indexStatMatchesFile( + fs, + entry, + { ...stat, mode: 0o100755 }, + new Date("2026-09-04T12:00:01Z"), + ), ).toBe(false); }); @@ -74,7 +85,7 @@ describe("index stat cache", () => { const stat = regularStat(); const entry = entryFor(stat); - expect(indexStatMatchesFile(entry, stat, stat.mtime)).toBe(false); + expect(indexStatMatchesFile(fs, entry, stat, stat.mtime)).toBe(false); }); test("rejects symlinks even when their metadata matches", () => { @@ -85,7 +96,7 @@ describe("index stat cache", () => { }); const entry = entryFor(stat); - expect(indexStatMatchesFile(entry, stat, new Date("2026-09-04T12:00:01Z"))).toBe(false); + expect(indexStatMatchesFile(fs, entry, stat, new Date("2026-09-04T12:00:01Z"))).toBe(false); }); test("uses optional ctime and identity fields when available", () => { @@ -99,10 +110,11 @@ describe("index stat cache", () => { const entry = entryFor(stat); const indexTimestamp = new Date("2026-09-04T12:00:01Z"); - expect(indexStatMatchesFile(entry, stat, indexTimestamp)).toBe(true); - expect(indexStatMatchesFile(entry, { ...stat, ino: 12 }, indexTimestamp)).toBe(false); + expect(indexStatMatchesFile(fs, entry, stat, indexTimestamp)).toBe(true); + expect(indexStatMatchesFile(fs, entry, { ...stat, ino: 12 }, indexTimestamp)).toBe(false); expect( indexStatMatchesFile( + fs, entry, { ...stat, ctime: new Date("2026-09-04T11:59:58Z") }, indexTimestamp, diff --git a/test/lib/unpack-trees/check-requirement.test.ts b/test/lib/unpack-trees/check-requirement.test.ts index c9074f50..57762e85 100644 --- a/test/lib/unpack-trees/check-requirement.test.ts +++ b/test/lib/unpack-trees/check-requirement.test.ts @@ -211,6 +211,21 @@ describe("checkSingleRequirement", () => { expect(result).toBe(UnpackError.NOT_UPTODATE_FILE); }); + test("fails with NOT_UPTODATE_FILE when only the worktree mode differs", async () => { + const state = makeState({ + indexHash: HASH_A, + indexMode: 0o100755, + existsOnDisk: true, + worktreeMode: 0o100644, + }); + const result = await checkSingleRequirement( + PreconditionRequirement.WORKTREE_MUST_BE_UPTODATE, + state, + HASH_C, + ); + expect(result).toBe(UnpackError.NOT_UPTODATE_FILE); + }); + test("escape hatch: passes when worktree matches result (three-way)", async () => { const state = makeState({ indexHash: HASH_A, diff --git a/test/lib/unpack-trees/helpers.ts b/test/lib/unpack-trees/helpers.ts index c88c6e47..73be3a24 100644 --- a/test/lib/unpack-trees/helpers.ts +++ b/test/lib/unpack-trees/helpers.ts @@ -33,10 +33,12 @@ interface PathStateInput { headHash?: ObjectId | null; remoteHash?: ObjectId | null; indexHash?: ObjectId | null; + indexMode?: number | null; indexStage?: number; existsOnDisk?: boolean; ignoredOnDisk?: boolean; worktreeHash?: ObjectId | null; + worktreeMode?: number | null; headMode?: string | null; remoteMode?: string | null; } @@ -54,6 +56,8 @@ export function makeState(input: PathStateInput = {}): PathState { const existsOnDisk = input.existsOnDisk ?? indexHash !== null; // Default: worktree matches index (clean worktree) const worktreeHash = input.worktreeHash !== undefined ? input.worktreeHash : indexHash; + const indexMode = input.indexMode ?? (indexHash ? 0o100644 : null); + const worktreeMode = input.worktreeMode !== undefined ? input.worktreeMode : indexMode; const ignoredOnDisk = input.ignoredOnDisk ?? false; @@ -63,10 +67,12 @@ export function makeState(input: PathStateInput = {}): PathState { headHash: input.headHash ?? null, remoteHash: input.remoteHash ?? null, indexHash, + indexMode, indexStage: input.indexStage ?? 0, existsOnDisk, isIgnoredOnDisk: async () => ignoredOnDisk, getWorktreeHash: async () => worktreeHash, + getWorktreeMode: async () => worktreeMode, headMode: input.headMode ?? (input.headHash ? "100644" : null), remoteMode: input.remoteMode ?? (input.remoteHash ? "100644" : null), }; diff --git a/test/lib/worktree.test.ts b/test/lib/worktree.test.ts new file mode 100644 index 00000000..a82beaf1 --- /dev/null +++ b/test/lib/worktree.test.ts @@ -0,0 +1,68 @@ +import { describe, expect, test } from "bun:test"; +import { MemoryFileSystem } from "../../src"; +import { writeObject } from "../../src/lib/object-db.ts"; +import { PackedObjectStore } from "../../src/lib/object-store.ts"; +import { FileSystemRefStore } from "../../src/lib/refs.ts"; +import type { GitContext } from "../../src/lib/types.ts"; +import { checkoutEntry } from "../../src/lib/worktree.ts"; + +const GIT_DIR = "/wt/.git"; + +async function context(fs: MemoryFileSystem): Promise { + await fs.mkdir(GIT_DIR, { recursive: true }); + return { + fs, + gitDir: GIT_DIR, + commonDir: GIT_DIR, + workTree: "/wt", + objectStore: new PackedObjectStore(fs, GIT_DIR), + refStore: new FileSystemRefStore(fs, GIT_DIR, GIT_DIR), + }; +} + +describe("checkoutEntry executable bit", () => { + test("applies 100755 and clears it for 100644 over an executable file", async () => { + const fs = new MemoryFileSystem(); + const ctx = await context(fs); + const hash = await writeObject(ctx, "blob", new TextEncoder().encode("#!/bin/sh\n")); + + await checkoutEntry(ctx, { path: "run.sh", hash, mode: 0o100755 }); + expect((await fs.stat("/wt/run.sh")).mode).toBe(0o100755); + + await checkoutEntry(ctx, { path: "run.sh", hash, mode: "100644" }); + expect((await fs.stat("/wt/run.sh")).mode).toBe(0o100644); + }); + + test("a FileSystem without chmod still checks out the file", async () => { + const fs = new MemoryFileSystem(); + Object.defineProperty(fs, "chmod", { value: undefined }); + const ctx = await context(fs); + const hash = await writeObject(ctx, "blob", new TextEncoder().encode("#!/bin/sh\n")); + + await checkoutEntry(ctx, { path: "run.sh", hash, mode: 0o100755 }); + expect(await fs.readFile("/wt/run.sh")).toBe("#!/bin/sh\n"); + expect((await fs.stat("/wt/run.sh")).mode).toBe(0o100644); + }); +}); + +class ModePreservingFs extends MemoryFileSystem { + override async writeFile(path: string, content: string | Uint8Array): Promise { + const existing = await this.stat(path).catch(() => null); + await super.writeFile(path, content); + if (existing) await this.chmod(path, existing.mode); + } +} + +describe("checkoutEntry on a filesystem whose writeFile keeps the old mode", () => { + test("a 100644 entry over an executable file clears the exec bit", async () => { + const fs = new ModePreservingFs(); + const ctx = await context(fs); + const hash = await writeObject(ctx, "blob", new TextEncoder().encode("#!/bin/sh\n")); + + await checkoutEntry(ctx, { path: "run.sh", hash, mode: 0o100755 }); + expect((await fs.stat("/wt/run.sh")).mode).toBe(0o100755); + + await checkoutEntry(ctx, { path: "run.sh", hash, mode: 0o100644 }); + expect((await fs.stat("/wt/run.sh")).mode).toBe(0o100644); + }); +}); diff --git a/test/memory-fs.test.ts b/test/memory-fs.test.ts index 6d764b57..38ff6733 100644 --- a/test/memory-fs.test.ts +++ b/test/memory-fs.test.ts @@ -358,3 +358,27 @@ describe("MemoryFileSystem", () => { expect(await fs.readFile("/repo/feature.txt")).toBe("new feature"); }); }); + +describe("MemoryFileSystem chmod", () => { + test("chmod sets permission bits and keeps the file type", async () => { + const fs = new MemoryFileSystem({ "/run.sh": "#!/bin/sh\n" }); + await fs.chmod("/run.sh", 0o755); + expect((await fs.stat("/run.sh")).mode).toBe(0o100755); + await fs.chmod("/run.sh", 0o644); + expect((await fs.stat("/run.sh")).mode).toBe(0o100644); + expect(await fs.readFile("/run.sh")).toBe("#!/bin/sh\n"); + }); + + test("chmod follows symlinks", async () => { + const fs = new MemoryFileSystem({ "/target": "x" }); + await fs.symlink("target", "/link"); + await fs.chmod("/link", 0o755); + expect((await fs.stat("/target")).mode).toBe(0o100755); + expect((await fs.lstat("/link")).mode).toBe(0o120000); + }); + + test("chmod throws ENOENT for a missing path", async () => { + const fs = new MemoryFileSystem(); + await expect(fs.chmod("/missing", 0o755)).rejects.toThrow("ENOENT"); + }); +}); diff --git a/test/tree-backed-fs.test.ts b/test/tree-backed-fs.test.ts new file mode 100644 index 00000000..3974b738 --- /dev/null +++ b/test/tree-backed-fs.test.ts @@ -0,0 +1,60 @@ +import { describe, expect, test } from "bun:test"; +import { writeBlob, writeTree } from "../src/repo/writing.ts"; +import { MemoryStorage } from "../src/server/memory-storage.ts"; +import { createStorageAdapter } from "../src/server/storage.ts"; +import { TreeBackedFs } from "../src/tree-backed-fs.ts"; + +async function treeFs(): Promise { + const repo = await createStorageAdapter(new MemoryStorage()).createRepo("test"); + const blob = await writeBlob(repo, "#!/bin/sh\n"); + const link = await writeBlob(repo, "plain.sh"); + const tree = await writeTree(repo, [ + { name: "plain.sh", hash: blob, mode: "100644" }, + { name: "exec.sh", hash: blob, mode: "100755" }, + { name: "link", hash: link, mode: "120000" }, + ]); + return new TreeBackedFs(repo.objectStore, tree, "/wt"); +} + +describe("TreeBackedFs chmod", () => { + test("chmod follows a tree-backed symlink to its target", async () => { + const fs = await treeFs(); + await fs.chmod("/wt/link", 0o755); + expect((await fs.lstat("/wt/plain.sh")).mode).toBe(0o100755); + expect((await fs.lstat("/wt/link")).isSymbolicLink).toBe(true); + expect((await fs.lstat("/wt/link")).mode).toBe(0o120000); + }); + + test("chmod follows an overlay symlink to its target", async () => { + const fs = await treeFs(); + await fs.symlink("exec.sh", "/wt/link2"); + await fs.chmod("/wt/link2", 0o644); + expect((await fs.lstat("/wt/exec.sh")).mode).toBe(0o100644); + expect((await fs.lstat("/wt/link2")).isSymbolicLink).toBe(true); + }); + + test("chmod on a tree-backed file changes its mode and keeps its content", async () => { + const fs = await treeFs(); + await fs.chmod("/wt/plain.sh", 0o755); + expect((await fs.lstat("/wt/plain.sh")).mode).toBe(0o100755); + expect((await fs.stat("/wt/plain.sh")).mode).toBe(0o100755); + expect(await fs.readFile("/wt/plain.sh")).toBe("#!/bin/sh\n"); + await fs.chmod("/wt/exec.sh", 0o644); + expect((await fs.lstat("/wt/exec.sh")).mode).toBe(0o100644); + }); + + test("chmod on an overlay file changes its mode", async () => { + const fs = await treeFs(); + await fs.writeFile("/wt/new.sh", "echo\n"); + await fs.chmod("/wt/new.sh", 0o755); + expect((await fs.stat("/wt/new.sh")).mode).toBe(0o100755); + expect(await fs.readFile("/wt/new.sh")).toBe("echo\n"); + }); + + test("chmod throws ENOENT for a missing or removed path", async () => { + const fs = await treeFs(); + await expect(fs.chmod("/wt/missing", 0o755)).rejects.toThrow("ENOENT"); + await fs.rm("/wt/plain.sh"); + await expect(fs.chmod("/wt/plain.sh", 0o755)).rejects.toThrow("ENOENT"); + }); +}); diff --git a/test/util.ts b/test/util.ts index b28ce270..11c30c89 100644 --- a/test/util.ts +++ b/test/util.ts @@ -104,3 +104,20 @@ export async function isFile(fs: IFileSystem, path: string): Promise { const stat = await fs.stat(path); return stat.isFile; } + +export async function setupExecBitRepo(): Promise { + const bash = createTestBash({ + files: { "/repo/run.sh": "#!/bin/sh\necho hi\n" }, + env: TEST_ENV, + }); + await bash.exec("git init && chmod 755 run.sh && git add run.sh && git commit -m exec"); + await bash.exec( + "git switch -c plain && chmod 644 run.sh && git add run.sh && git commit -m plain", + ); + await bash.exec("git switch main"); + return bash; +} + +export async function permissionBits(fs: IFileSystem, path: string): Promise { + return (await fs.stat(path)).mode & 0o777; +}