Skip to content
Open
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
15 changes: 9 additions & 6 deletions docs/CLI.md
Original file line number Diff line number Diff line change
Expand Up @@ -378,6 +378,7 @@ Options:
--bare Create a bare clone
-b, --branch <string> Checkout this branch instead of HEAD
--depth <number> Create a shallow clone with history truncated to N commits
--filter <string> Omit blob contents (requires --no-checkout or --bare)
--single-branch Clone only the history of the specified or default branch
--no-single-branch Clone all branches even with --depth
--no-tags Don't clone any tags
Expand Down Expand Up @@ -485,12 +486,14 @@ Arguments:
refspec... Refspec(s) to fetch

Options:
--all Fetch from all remotes
-p, --prune Remove stale remote-tracking refs
--tags Also fetch tags
--depth <number> Limit fetching to the specified number of commits
--unshallow Convert a shallow repository to a complete one
-q, --quiet be more quiet
--all Fetch from all remotes
-p, --prune Remove stale remote-tracking refs
--tags Also fetch tags
--no-tags Do not fetch tags
--filter <string> Omit blob contents (blob:none)
--depth <number> Limit fetching to the specified number of commits
--unshallow Convert a shallow repository to a complete one
-q, --quiet be more quiet
```

## git gc
Expand Down
25 changes: 25 additions & 0 deletions docs/CLIENT.md
Original file line number Diff line number Diff line change
Expand Up @@ -35,6 +35,31 @@ const git = createGit({

## Repository access

### Partial clone

Use `fetch --filter=blob:none` or `clone --filter=blob:none --no-checkout` (or `--bare`) to transfer commits and trees without blobs. The remote must advertise the `filter` capability. The filter and promisor remote are saved in repository configuration and reused by later fetches. `fetch --no-tags` and the remote's saved `tagOpt` suppress automatic tag downloads.

Use `fetchObjects(repo, objectIds, { remote: "origin", batchSize: 128 })` from `just-git/repo` to retrieve selected missing objects. Requests use the configured transport, credentials, network policy and fetch hooks without updating refs, `FETCH_HEAD` or shallow boundaries. The server must allow requests for reachable object IDs. Existing objects and duplicates are skipped. Request blob IDs from trees to avoid downloading the graph reachable from a commit.

Object reads never download implicitly; retrieve required blobs before reading them. Partial repositories support filtered fetch and the repository API, but not general checkout, diff or merge operations. Pull, GC and repack reject partial repositories before modifying them. The local/cross-VFS transport filters its outgoing pack but still reads source blobs during enumeration; Smart HTTP avoids transferring them.

```ts
import { fetchObjects, flattenTree, readCommit, resolveRef } from "just-git/repo";

await git.exec("fetch --depth=1 --filter=blob:none --no-tags origin");
const repo = await git.findRepo();
if (repo) {
const revision = await resolveRef(repo, "refs/remotes/origin/main");
if (!revision) throw new Error("Remote main branch not found");
const entries = await flattenTree(repo, (await readCommit(repo, revision)).tree);
const selected = entries.filter(({ path }) => path.startsWith("src/"));
await fetchObjects(
repo,
selected.map(({ hash }) => hash),
);
}
```

`git.findRepo()` returns a `GitContext` for the current working directory, the bridge between command execution via `exec` and programmatic access via the [repo module](REPO.md). Like `exec`, it uses the instance's default `fs` and `cwd`, with optional per-call overrides. The returned context carries all operator-level extensions (hooks, identity, credentials, config overrides) configured on the instance.

```ts
Expand Down
4 changes: 4 additions & 0 deletions docs/FILE_REFERENCE.md
Original file line number Diff line number Diff line change
Expand Up @@ -164,6 +164,10 @@ Exported symbols from `src/lib/`. Generated by `bun scripts/gen-lib-reference.ts

- `deflate`, `inflate`, `inflateObject`

### lib/partial-clone.ts

- `validateObjectFilter`, `configurePartialClone`, `requireCompleteRepository`

### lib/patch-id.ts

- `computePatchId`
Expand Down
4 changes: 3 additions & 1 deletion docs/REPO.md
Original file line number Diff line number Diff line change
Expand Up @@ -10,7 +10,7 @@ For command-execution configuration, see [CLIENT.md](CLIENT.md). For the embedda

## GitRepo

Everything in this module operates on `GitRepo`, a minimal interface representing a git repository as an object store and a ref store:
Most operations in this module accept `GitRepo`, a minimal interface representing a git repository as an object store and a ref store:

```ts
interface GitRepo {
Expand All @@ -21,6 +21,8 @@ interface GitRepo {

This is the type that unifies the client and server sides of just-git. Any function that accepts `GitRepo` works identically regardless of what's behind it: an in-memory VFS, a SQLite database, Postgres, or a custom implementation.

`fetchObjects(ctx, objectIds, options?)` requires a `GitContext` from `git.findRepo()` because it uses configured remotes, credentials and network policy. It retrieves missing objects in batches without changing refs or shallow boundaries; a storage-backed `GitRepo` alone is insufficient. See [Partial clone](CLIENT.md#partial-clone).

### How you get a GitRepo

**From a virtual filesystem**: `findRepo` walks up from a path looking for a `.git` directory and returns a `GitContext` (which extends `GitRepo` with filesystem access, paths, and credentials):
Expand Down
21 changes: 20 additions & 1 deletion src/commands/clone.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@ import type { GitExtensions } from "../git.ts";
import { isRejection } from "../hooks.ts";
import { err, fatal, quietFlag } from "../lib/command-utils.ts";
import { readConfig, writeConfig } from "../lib/config.ts";
import { validateObjectFilter } from "../lib/partial-clone.ts";
import {
buildIndex,
defaultStat,
Expand Down Expand Up @@ -32,13 +33,17 @@ export function registerCloneCommand(parent: Command, ext?: GitExtensions) {
bare: f().describe("Create a bare clone"),
branch: o.string().alias("b").describe("Checkout this branch instead of HEAD"),
depth: o.number().describe("Create a shallow clone with history truncated to N commits"),
filter: o.string().describe("Omit blob contents (requires --no-checkout or --bare)"),
singleBranch: f().describe("Clone only the history of the specified or default branch"),
noSingleBranch: f().describe("Clone all branches even with --depth"),
noTags: f().describe("Don't clone any tags"),
noCheckout: f().alias("n").describe("Don't create a checkout"),
quiet: quietFlag("be more quiet"),
},
handler: async (args, ctx) => {
const filter = args.filter === undefined ? undefined : validateObjectFilter(args.filter);
if (filter && !args.noCheckout && !args.bare)
return fatal("Filtered clone requires --no-checkout or --bare; hydrate objects explicitly");
const repository = args.repository;
if (!repository) {
return fatal("You must specify a repository to clone.");
Expand Down Expand Up @@ -145,6 +150,12 @@ export function registerCloneCommand(parent: Command, ext?: GitExtensions) {
url: sourcePath,
fetch: "+refs/heads/*:refs/remotes/origin/*",
};
if (filter) {
config['remote "origin"']!.promisor = "true";
config['remote "origin"']!.partialclonefilter = filter;
config.core = { ...config.core, repositoryformatversion: "1" };
config.extensions = { ...config.extensions, partialclone: "origin" };
}
await writeConfig(newCtx, config);
await ext?.hooks?.postClone?.({
repo: newCtx,
Expand Down Expand Up @@ -220,6 +231,12 @@ export function registerCloneCommand(parent: Command, ext?: GitExtensions) {
remoteSection.tagOpt = "--no-tags";
}
config['remote "origin"'] = remoteSection;
if (filter) {
remoteSection.promisor = "true";
remoteSection.partialclonefilter = filter;
config.core = { ...config.core, repositoryformatversion: "1" };
config.extensions = { ...config.extensions, partialclone: "origin" };
}

// Build wants list — single-branch limits to target branch only
const wants: ObjectId[] = [];
Expand All @@ -244,7 +261,9 @@ export function registerCloneCommand(parent: Command, ext?: GitExtensions) {
}

const shallowOpts: ShallowFetchOptions | undefined =
depthOpt !== undefined && depthOpt > 0 ? { depth: depthOpt } : undefined;
(depthOpt !== undefined && depthOpt > 0) || filter
? { depth: depthOpt, filter }
: undefined;

if (wants.length > 0) {
const fetchResult = await transport.fetch(wants, [], shallowOpts);
Expand Down
28 changes: 25 additions & 3 deletions src/commands/fetch.ts
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,7 @@ import {
type TransferRefLine,
} from "../lib/command-utils.ts";
import { readConfig } from "../lib/config.ts";
import { configurePartialClone, validateObjectFilter } from "../lib/partial-clone.ts";
import {
autoFollowReachableTags,
collectFetchHaves,
Expand Down Expand Up @@ -47,11 +48,14 @@ export function registerFetchCommand(parent: Command, ext?: GitExtensions) {
all: f().describe("Fetch from all remotes"),
prune: f().alias("p").describe("Remove stale remote-tracking refs"),
tags: f().describe("Also fetch tags"),
noTags: f().describe("Do not fetch tags"),
filter: o.string().describe("Omit blob contents (blob:none)"),
depth: o.number().describe("Limit fetching to the specified number of commits"),
unshallow: f().describe("Convert a shallow repository to a complete one"),
quiet: quietFlag("be more quiet"),
},
handler: async (args, ctx) => {
if (args.tags && args.noTags) return fatal("--tags and --no-tags cannot be used together");
const gitCtxOrError = await requireGitContext(ctx.fs, ctx.cwd, ext);
if (isCommandError(gitCtxOrError)) return gitCtxOrError;
const gitCtx = gitCtxOrError;
Expand Down Expand Up @@ -86,6 +90,8 @@ export function registerFetchCommand(parent: Command, ext?: GitExtensions) {
ext,
depth,
args.quiet,
args.filter,
args.noTags,
);
if (result.stderr) allStderr.push(result.stderr);
if (result.exitCode !== 0) lastExit = result.exitCode;
Expand All @@ -104,6 +110,8 @@ export function registerFetchCommand(parent: Command, ext?: GitExtensions) {
ext,
depth,
args.quiet,
args.filter,
args.noTags,
);
},
});
Expand Down Expand Up @@ -282,10 +290,16 @@ async function fetchOneRemote(
ext?: GitExtensions,
depth?: number,
quiet = false,
requestedFilter?: string,
noTags = false,
): Promise<ExecResult> {
const resolved = await resolveRemoteTransportOrError(gitCtx, remoteName, env);
if (isCommandError(resolved)) return resolved;
const { transport, config } = resolved;
const selectedFilter = requestedFilter ?? config.partialCloneFilter;
const filter = selectedFilter === undefined ? undefined : validateObjectFilter(selectedFilter);
if (filter && config.anonymous) return fatal("Filtered fetch requires a configured remote");
const suppressTags = noTags || config.noTags;

const hasExplicitRefspecs = !!rawRefspecs && rawRefspecs.length > 0;
// A raw-URL (anonymous) remote has no remote-tracking namespace. Like git,
Expand Down Expand Up @@ -316,6 +330,7 @@ async function fetchOneRemote(
}
}
}
if (filter) await configurePartialClone(gitCtx, remoteName, filter);
return { stdout: "", stderr: "", exitCode: 0 };
}

Expand Down Expand Up @@ -354,11 +369,16 @@ async function fetchOneRemote(
const haveSet = new Set(haves);
const filteredWants = wants.filter((w) => !haveSet.has(w));

const { existingShallows, shallowOpts } = await prepareShallowFetch(gitCtx, depth);
const { existingShallows, shallowOpts } = await prepareShallowFetch(gitCtx, depth, filter);

// When depth/unshallow is requested, we must call fetch even with no
// new wants so the shallow boundary negotiation can happen.
const effectiveWants = filteredWants.length > 0 ? filteredWants : shallowOpts ? wants : [];
const effectiveWants =
filteredWants.length > 0
? filteredWants
: depth !== undefined || (filter && !config.partialCloneFilter)
? wants
: [];

if (effectiveWants.length > 0) {
const fetchResult = await transport.fetch(effectiveWants, haves, shallowOpts);
Expand All @@ -367,6 +387,7 @@ async function fetchOneRemote(
await applyShallowUpdates(gitCtx, fetchResult.shallowUpdates, existingShallows);
}
}
if (filter) await configurePartialClone(gitCtx, remoteName, filter);

const ident = await getReflogIdentity(gitCtx, env);
const { refLines, hadTagRejection, appliedUpdates } = await applyFetchRefUpdates(
Expand All @@ -381,13 +402,14 @@ async function fetchOneRemote(
refLines.push({ prefix: " * branch", from: "HEAD", to: "FETCH_HEAD" });
}

if (!tags) {
if (!tags && !suppressTags) {
const tagLines = await autoFollowReachableTags({
gitCtx,
transport,
remoteRefs,
ident,
reflogAction: "fetch",
filter,
});
if (!quiet) refLines.push(...tagLines);
}
Expand Down
2 changes: 2 additions & 0 deletions src/commands/gc.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
import type { GitExtensions } from "../git.ts";
import { requireCompleteRepository } from "../lib/partial-clone.ts";
import { isCommandError, quietFlag, requireGitContext } from "../lib/command-utils.ts";
import { type GitConfig, readConfig } from "../lib/config.ts";
import { collectAllRoots } from "../lib/gc-roots.ts";
Expand All @@ -22,6 +23,7 @@ export function registerGcCommand(parent: Command, ext?: GitExtensions) {
const gitCtxOrError = await requireGitContext(ctx.fs, ctx.cwd, ext);
if (isCommandError(gitCtxOrError)) return gitCtxOrError;
const gitCtx = gitCtxOrError;
await requireCompleteRepository(gitCtx);

// Step 1: Pack refs
await writePackedRefs(gitCtx);
Expand Down
2 changes: 2 additions & 0 deletions src/commands/pull.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
import type { GitExtensions } from "../git.ts";
import { requireCompleteRepository } from "../lib/partial-clone.ts";
import { isRejection } from "../hooks.ts";
import {
buildAbbrevResolver,
Expand Down Expand Up @@ -87,6 +88,7 @@ export function registerPullCommand(parent: Command, ext?: GitExtensions) {
const gitCtxOrError = await requireGitContext(ctx.fs, ctx.cwd, ext);
if (isCommandError(gitCtxOrError)) return gitCtxOrError;
const gitCtx = gitCtxOrError;
await requireCompleteRepository(gitCtx, "Pull; use filtered fetch and the repo SDK instead");

const depthResult = await normalizeFetchDepth(gitCtx, args);
if (isCommandError(depthResult)) return depthResult;
Expand Down
2 changes: 2 additions & 0 deletions src/hooks.ts
Original file line number Diff line number Diff line change
Expand Up @@ -194,6 +194,8 @@ export interface PreCheckoutEvent {

/** Fired before a fetch begins. Return a {@link Rejection} to block. */
export interface PreFetchEvent {
/** Explicit object hydration requests; refspecs is empty for these calls. */
readonly objectIds?: readonly ObjectId[];
readonly repo: GitRepo;
readonly remote: string;
readonly url: string;
Expand Down
13 changes: 10 additions & 3 deletions src/lib/fetch-helpers.ts
Original file line number Diff line number Diff line change
Expand Up @@ -71,11 +71,13 @@ export async function collectFetchHaves(gitCtx: GitContext): Promise<ObjectId[]>
export async function prepareShallowFetch(
gitCtx: GitContext,
depth?: number,
filter?: "blob:none",
): Promise<PreparedShallowFetch> {
const existingShallows = depth !== undefined ? await readShallowCommits(gitCtx) : undefined;
const existingShallows =
depth !== undefined || filter ? await readShallowCommits(gitCtx) : undefined;
return {
existingShallows,
shallowOpts: depth !== undefined ? { depth, existingShallows } : undefined,
shallowOpts: depth !== undefined || filter ? { depth, existingShallows, filter } : undefined,
};
}

Expand All @@ -85,6 +87,7 @@ export async function autoFollowReachableTags(options: {
remoteRefs: RemoteRef[];
ident: ReflogWriteIdentity;
reflogAction: "fetch" | "pull";
filter?: "blob:none";
}): Promise<TransferRefLine[]> {
const { gitCtx, transport, remoteRefs, ident, reflogAction } = options;

Expand All @@ -106,7 +109,11 @@ export async function autoFollowReachableTags(options: {
}
}
if (tagObjectWants.length > 0) {
await transport.fetch(tagObjectWants, await collectFetchHaves(gitCtx));
await transport.fetch(
tagObjectWants,
await collectFetchHaves(gitCtx),
options.filter ? { filter: options.filter } : undefined,
);
}

const refLines: TransferRefLine[] = [];
Expand Down
46 changes: 46 additions & 0 deletions src/lib/partial-clone.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,46 @@
import { parseConfig, readConfig, setConfigValueRaw } from "./config.ts";
import { join } from "./path.ts";
import type { GitContext } from "./types.ts";

export function validateObjectFilter(filter: string): "blob:none" {
if (filter !== "blob:none") throw new Error("Only the blob:none object filter is supported");
return filter;
}

/** Persist the promise that deliberately omitted objects remain on this remote. */
export async function configurePartialClone(ctx: GitContext, remote: string, filter: "blob:none") {
const path = join(ctx.commonDir, "config");
let raw = await ctx.fs.readFile(path);
const config = parseConfig(raw);
const remoteSection = `remote "${remote}"`;
const section = config[remoteSection];
if (!section?.url) throw new Error("Filtered fetch requires a configured remote");
if (
section.promisor === "true" &&
section.partialclonefilter === filter &&
config.extensions?.partialclone === remote
)
return;
for (const [section, key, value] of [
[remoteSection, "promisor", "true"],
[remoteSection, "partialclonefilter", filter],
["core", "repositoryformatversion", "1"],
["extensions", "partialclone", remote],
] as const)
raw = setConfigValueRaw(raw, section, key, value);
await ctx.fs.writeFile(path, raw);
}

/** Maintenance must not mistake promised-but-absent objects for corruption. */
export async function requireCompleteRepository(
ctx: GitContext,
operation = "Repository maintenance",
): Promise<void> {
const config = await readConfig(ctx);
if (
config.extensions?.partialclone ||
Object.values(config).some((section) => section.promisor === "true")
) {
throw new Error(`${operation} is not supported for partial clones`);
}
}
2 changes: 2 additions & 0 deletions src/lib/repack.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
import type { FileSystem } from "../fs.ts";
import { requireCompleteRepository } from "./partial-clone.ts";
import { findBestDeltas } from "./pack/delta.ts";
import { buildPackIndexFromMeta, PackIndex } from "./pack/pack-index.ts";
import { type DeltaPackInput, writePackDeltified } from "./pack/packfile.ts";
Expand Down Expand Up @@ -62,6 +63,7 @@ interface RepackResult {
*/
export async function repackFromTips(options: RepackOptions): Promise<RepackResult | null> {
const { gitCtx, fs, tips, cleanup } = options;
await requireCompleteRepository(gitCtx);
const window = options.window ?? 10;
const depth = options.depth ?? 50;

Expand Down
Loading