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/global-config-option.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
"just-git": patch
---

`git -c <name>=<value> <cmd>` and `git -C <path> <cmd>` now run the command with the overlay or directory applied, instead of printing help and exiting 0. An unknown leading option exits 129 with `unknown option: <opt>`. Config override keys are matched case-insensitively, as in git.
2 changes: 1 addition & 1 deletion docs/FILE_REFERENCE.md
Original file line number Diff line number Diff line change
Expand Up @@ -38,7 +38,7 @@ Exported symbols from `src/lib/`. Generated by `bun scripts/gen-lib-reference.ts

### lib/config.ts

- `parseConfig`, `parseConfigMulti`, `formatConfigValue`, `serializeConfig`, `setConfigValueRaw`, `unsetConfigValueRaw`, `readConfig`, `writeConfig`, `getConfigValue`, `setConfigValue`, `getConfigValueAll`, `addConfigValueRaw`, `addConfigValue`, `unsetConfigValue`
- `parseConfig`, `parseConfigMulti`, `formatConfigValue`, `serializeConfig`, `setConfigValueRaw`, `unsetConfigValueRaw`, `readConfig`, `writeConfig`, `getConfigValue`, `canonicalConfigKey`, `setConfigValue`, `getConfigValueAll`, `addConfigValueRaw`, `addConfigValue`, `unsetConfigValue`
- **types**: `GitConfigSection`, `GitConfig`, `GitConfigMultiSection`, `GitConfigMulti`

### lib/date.ts
Expand Down
4 changes: 2 additions & 2 deletions src/commands/init.ts
Original file line number Diff line number Diff line change
Expand Up @@ -17,8 +17,8 @@ export function registerInitCommand(parent: Command, ext?: GitExtensions) {
handler: async (args, ctx) => {
const initialBranch =
args.initialBranch ??
ext?.configOverrides?.locked?.["init.defaultBranch"] ??
ext?.configOverrides?.defaults?.["init.defaultBranch"];
ext?.configOverrides?.locked?.["init.defaultbranch"] ??
ext?.configOverrides?.defaults?.["init.defaultbranch"];
const targetDir = args.directory ? resolve(ctx.cwd, args.directory) : ctx.cwd;

if (args.directory) {
Expand Down
113 changes: 110 additions & 3 deletions src/git.ts
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,8 @@ import {
} from "./hooks.ts";
import type { MergeDriver } from "./lib/merge-ort.ts";
import { contextFromExtensions } from "./lib/command-utils.ts";
import { canonicalConfigKey } from "./lib/config.ts";
import { resolve } from "./lib/path.ts";
import { findRepo as findRepoOnFs } from "./lib/repo.ts";
import type { CredentialCache } from "./lib/transport/remote.ts";
import type { GitContext, ObjectStore, RefStore, RemoteResolver } from "./lib/types.ts";
Expand Down Expand Up @@ -243,6 +245,87 @@ function mergeIdentityIntoConfig(
};
}

function canonicalizeOverrides(config: ConfigOverrides | undefined): ConfigOverrides | undefined {
if (!config) return config;
const canon = (tier: Record<string, string>) =>
Object.fromEntries(Object.entries(tier).map(([k, v]) => [canonicalConfigKey(k), v]));
return {
...(config.locked ? { locked: canon(config.locked) } : {}),
...(config.defaults ? { defaults: canon(config.defaults) } : {}),
};
}

const GLOBAL_USAGE =
"usage: git [--version] [--help] [-C <path>] [-c <name>=<value>]\n" +
" [-p | --paginate | -P | --no-pager] <command> [<args>]\n";

interface GlobalOptions {
chdirs: string[];
config: Record<string, string> | undefined;
args: string[];
}

function usageError(message: string): ExecResult {
return { stdout: "", stderr: `${message}\n${GLOBAL_USAGE}`, exitCode: 129 };
}

function configParseError(message: string): ExecResult {
return {
stdout: "",
stderr: `error: ${message}\nfatal: unable to parse command-line config\n`,
exitCode: 128,
};
}

function parseGlobalOptions(args: string[]): GlobalOptions | ExecResult {
const chdirs: string[] = [];
let config: Record<string, string> | undefined;
let i = 0;
while (i < args.length) {
const arg = args[i]!;
if (!arg.startsWith("-") || arg === "-" || arg === "--help" || arg === "--version") break;
i++;
switch (arg) {
case "-c": {
const spec = args[i++];
if (spec === undefined) return usageError("-c expects a configuration string");
const eq = spec.indexOf("=");
const key = eq === -1 ? spec : spec.slice(0, eq);
if (key === "") return configParseError("empty config key");
if (!key.includes(".")) return configParseError(`key does not contain a section: ${key}`);
config ??= {};
config[canonicalConfigKey(key)] = eq === -1 ? "true" : spec.slice(eq + 1);
break;
}
case "-C": {
const dir = args[i++];
if (dir === undefined) return usageError("no directory given for -C");
chdirs.push(dir);
break;
}
case "-p":
case "--paginate":
case "-P":
case "--no-pager":
break;
default:
return usageError(`unknown option: ${arg}`);
}
}
return { chdirs, config, args: args.slice(i) };
}

function withCommandLineConfig(
overrides: ConfigOverrides | undefined,
config: Record<string, string>,
): ConfigOverrides {
return { ...overrides, locked: { ...config, ...overrides?.locked } };
}

async function isDirectory(fs: FileSystem, path: string): Promise<boolean> {
return (await fs.exists(path)) && (await fs.stat(path)).isDirectory;
}

/**
* Git command handler. Runs git subcommands against a virtual filesystem.
*
Expand Down Expand Up @@ -287,7 +370,9 @@ export class Git {
this.blocked = options?.disabled?.length ? new Set<string>(options.disabled) : null;
const network = options?.network;

const configOverrides = mergeIdentityIntoConfig(options?.identity, options?.config);
const configOverrides = canonicalizeOverrides(
mergeIdentityIntoConfig(options?.identity, options?.config),
);

const gitDirExt = options?.gitDir
? {
Expand Down Expand Up @@ -377,10 +462,26 @@ export class Git {
return this.execute(args, { fs, cwd, env, stdin: ctx?.stdin ?? "" });
};

execute = (args: string[], ctx: CommandContext): Promise<ExecResult> => {
execute = (rawArgs: string[], ctx: CommandContext): Promise<ExecResult> => {
return this.withLock(ctx.fs, async () => {
const global = parseGlobalOptions(rawArgs);
if ("exitCode" in global) return global;
const { args, config, chdirs } = global;
const command = args[0] ?? "";

let cwd = ctx.cwd;
for (const dir of chdirs) {
cwd = resolve(cwd, dir);
if (!(await isDirectory(ctx.fs, cwd))) {
return {
stdout: "",
stderr: `fatal: cannot change to '${dir}': No such file or directory\n`,
exitCode: 128,
};
}
}
if (cwd !== ctx.cwd) ctx = { ...ctx, cwd };

if (command === "--version" || command === "version") {
return {
stdout: `just-git version ${VERSION} (virtual git implementation)\n`,
Expand Down Expand Up @@ -422,7 +523,13 @@ export class Git {
}
}

const result = await this.inner.execute(args, ctx);
const inner = config
? createGitCommand({
...this.ext,
configOverrides: withCommandLineConfig(this.ext.configOverrides, config),
}).toCommand()
: this.inner;
const result = await inner.execute(args, ctx);

if (this.hooks?.afterCommand) {
await this.hooks.afterCommand({
Expand Down
25 changes: 21 additions & 4 deletions src/lib/config.ts
Original file line number Diff line number Diff line change
Expand Up @@ -513,15 +513,31 @@ export async function getConfigValue(
dottedKey: string,
): Promise<string | undefined> {
const overrides = ctx.configOverrides;
const locked = overrides?.locked?.[dottedKey];
const canonical = canonicalConfigKey(dottedKey);
const locked = overrides?.locked?.[canonical];
if (locked !== undefined) return locked;

const config = await readConfig(ctx);
const { section, key } = parseDottedKey(dottedKey);
const fromFile = config[section]?.[key];
if (fromFile !== undefined) return fromFile;

return overrides?.defaults?.[dottedKey];
return overrides?.defaults?.[canonical];
}

/**
* Lowercase the section and variable name of a dotted key, leaving any
* subsection as typed, the way git compares config keys.
*/
export function canonicalConfigKey(dottedKey: string): string {
const first = dottedKey.indexOf(".");
if (first === -1) return dottedKey.toLowerCase();
const last = dottedKey.lastIndexOf(".");
return (
dottedKey.slice(0, first).toLowerCase() +
dottedKey.slice(first, last) +
dottedKey.slice(last).toLowerCase()
);
}

/**
Expand Down Expand Up @@ -550,7 +566,8 @@ export async function setConfigValue(
*/
export async function getConfigValueAll(ctx: GitContext, dottedKey: string): Promise<string[]> {
const overrides = ctx.configOverrides;
const locked = overrides?.locked?.[dottedKey];
const canonical = canonicalConfigKey(dottedKey);
const locked = overrides?.locked?.[canonical];
if (locked !== undefined) return [locked];

const raw = await readConfigRaw(ctx);
Expand All @@ -561,7 +578,7 @@ export async function getConfigValueAll(ctx: GitContext, dottedKey: string): Pro
if (fromFile && fromFile.length > 0) return fromFile;
}

const def = overrides?.defaults?.[dottedKey];
const def = overrides?.defaults?.[canonical];
if (def !== undefined) return [def];
return [];
}
Expand Down
Loading
Loading