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
5 changes: 5 additions & 0 deletions .changeset/checkout-exec-bit.md
Original file line number Diff line number Diff line change
@@ -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.
4 changes: 2 additions & 2 deletions docs/FILE_REFERENCE.md
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand Down Expand Up @@ -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

Expand Down
10 changes: 4 additions & 6 deletions src/commands/diff.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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",
});
}
Expand Down Expand Up @@ -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",
Expand Down Expand Up @@ -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,
Expand Down
2 changes: 2 additions & 0 deletions src/commands/mv.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
11 changes: 10 additions & 1 deletion src/fs.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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.
*/
Expand Down Expand Up @@ -98,6 +100,13 @@ export interface FileSystem {
* @throws Error if `path` already exists.
*/
symlink?(target: string, path: string): Promise<void>;
/**
* 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<void>;
/**
* Move/rename a file or directory from `src` to `dest`.
*
Expand Down
8 changes: 6 additions & 2 deletions src/lib/command-utils.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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<string, { hash: string }>): boolean {
const stage0 = new Map<string, { hash: string }>();
export function hasStagedChanges(
index: Index,
headMap: Map<string, { hash: string; mode: string }>,
): boolean {
const stage0 = new Map<string, { hash: string; mode: number }>();
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;
Expand Down
9 changes: 6 additions & 3 deletions src/lib/diff-algorithm.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down Expand Up @@ -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);
Expand Down Expand Up @@ -1281,15 +1285,14 @@ 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 === "";

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`;
}
Expand Down
33 changes: 29 additions & 4 deletions src/lib/index.ts
Original file line number Diff line number Diff line change
@@ -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 ───────────────────────────────────────────────────────
Expand Down Expand Up @@ -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);
Expand Down Expand Up @@ -231,6 +232,30 @@ export function gitModeFromFileStat(stat: FileStat): number {
return stat.mode & 0o111 ? 0o100755 : 0o100644;
}

function fileStandsInForSymlink(
fs: FileSystem,
entry: Pick<IndexEntry, "mode">,
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<IndexEntry, "mode"> | 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
Expand All @@ -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;
}
Expand Down
2 changes: 1 addition & 1 deletion src/lib/status-format.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand Down
10 changes: 10 additions & 0 deletions src/lib/symlink.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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";
Expand Down
1 change: 1 addition & 0 deletions src/lib/types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -302,4 +302,5 @@ export interface WorkTreeDiff {
indexHash?: ObjectId;
/** Hash computed from modified worktree content during comparison. */
worktreeHash?: ObjectId;
worktreeMode?: number;
}
Loading
Loading