diff --git a/apps/ui/src/App.tsx b/apps/ui/src/App.tsx
index 8c42fdf..10adf89 100644
--- a/apps/ui/src/App.tsx
+++ b/apps/ui/src/App.tsx
@@ -13,6 +13,9 @@ const loadingStore = createRepositoryStore({
kind: 'unavailable',
message: 'No Repository is loaded.',
}),
+ mutateFiles: async () => {
+ throw new Error('File mutations are unavailable while loading.');
+ },
searchBranches: async () => ({ refsRevision: 0, candidates: [] }),
switchBranch: async () => {
throw new Error('Branch switching is unavailable while loading.');
diff --git a/apps/ui/src/ChangeGroups.tsx b/apps/ui/src/ChangeGroups.tsx
index 2c00cb7..9aaa8ac 100644
--- a/apps/ui/src/ChangeGroups.tsx
+++ b/apps/ui/src/ChangeGroups.tsx
@@ -13,10 +13,15 @@ export function ChangeGroups({
worktree,
selectedFileId,
onSelect,
+ onMutate,
}: {
readonly worktree: WorktreeOverviewSnapshot;
readonly selectedFileId: FileId | null;
readonly onSelect: (fileId: FileId) => void;
+ readonly onMutate: (
+ kind: 'stage' | 'unstage',
+ fileIds: readonly FileId[],
+ ) => void;
}) {
if (worktree.changes.length === 0) {
return
No Changed Files in this Worktree.
;
@@ -33,6 +38,20 @@ export function ChangeGroups({
{group.label} {changes.length}
+ {group.kind === 'conflict' ? null : (
+
+ )}
{changes.map((change) => (
-
@@ -47,6 +66,20 @@ export function ChangeGroups({
renamed from {change.previousDisplayPath}
)}
+ {group.kind === 'conflict' ? null : (
+
+ )}
))}
diff --git a/apps/ui/src/RepositoryOverview.interactions.test.tsx b/apps/ui/src/RepositoryOverview.interactions.test.tsx
index f804c6c..d7215b7 100644
--- a/apps/ui/src/RepositoryOverview.interactions.test.tsx
+++ b/apps/ui/src/RepositoryOverview.interactions.test.tsx
@@ -56,6 +56,48 @@ describe('Repository overview interactions', () => {
expect(container.textContent).toContain('Unified');
});
+ it('offers file and group Stage and Unstage actions', async () => {
+ const fixture = createOverviewFixture('changed-worktree');
+ const mutations: unknown[] = [];
+ const store = createRepositoryStore({
+ ...fixture.source,
+ async mutateFiles(request: unknown) {
+ mutations.push(request);
+ return {
+ kind: 'succeeded' as const,
+ operationId: operationIdSchema.parse(
+ 'operation_00000000000000000000000000000002',
+ ),
+ result: { kind: 'files' as const, affectedCount: 2 },
+ };
+ },
+ });
+ act(() => root.render());
+
+ expect(button('Unstage README.md')).toBeDefined();
+ expect(button('Stage src/app.ts')).toBeDefined();
+ expect(button('Stage notes.txt')).toBeDefined();
+ expect(button('Unstage all Staged Changes')).toBeDefined();
+ expect(button('Stage all Changes')).toBeDefined();
+ expect(button('Stage all Untracked Files')).toBeDefined();
+
+ await act(async () => button('Stage all Changes').click());
+
+ const source = fixture.source.getSnapshot();
+ if (source.kind !== 'repository') throw new Error('Expected Repository');
+ const worktree = source.snapshot.worktrees[0]!;
+ expect(mutations).toEqual([
+ {
+ kind: 'stage',
+ worktreeId: worktree.worktreeId,
+ expectedWorktreeRevision: worktree.worktreeRevision,
+ fileIds: worktree.changes
+ .filter(({ kind }) => kind === 'change')
+ .map(({ fileId }) => fileId),
+ },
+ ]);
+ });
+
it('shows truthful metadata when Diff content cannot be rendered', async () => {
const fixture = createOverviewFixture('changed-worktree');
const store = createRepositoryStore({
diff --git a/apps/ui/src/RepositoryOverview.tsx b/apps/ui/src/RepositoryOverview.tsx
index 1a5ff2e..a42a2e9 100644
--- a/apps/ui/src/RepositoryOverview.tsx
+++ b/apps/ui/src/RepositoryOverview.tsx
@@ -491,7 +491,31 @@ export function RepositoryOverview({
worktree={selected}
selectedFileId={state.selectedFileId}
onSelect={(fileId) => store.selectFile(fileId)}
+ onMutate={(kind, fileIds) => store.mutateFiles(kind, fileIds)}
/>
+ {state.fileMutationResult === null ? null : (
+
+ File operation result
+ {'message' in state.fileMutationResult ? (
+ {state.fileMutationResult.message}
+ ) : (
+ Changed Files updated.
+ )}
+ {'effects' in state.fileMutationResult &&
+ state.fileMutationResult.effects !== undefined ? (
+
+ {state.fileMutationResult.effects.map((effect) => (
+ -
+ {effect.label} —{' '}
+ {effect.kind === 'succeeded'
+ ? 'Succeeded'
+ : `Failed: ${effect.message}`}
+
+ ))}
+
+ ) : null}
+
+ )}
Diff
diff --git a/apps/ui/src/overview-fixtures.ts b/apps/ui/src/overview-fixtures.ts
index 96b10f9..4c3a2c2 100644
--- a/apps/ui/src/overview-fixtures.ts
+++ b/apps/ui/src/overview-fixtures.ts
@@ -103,6 +103,9 @@ function createMutableFixture(
message: 'Native actions are not installed in this fixture.',
};
},
+ async mutateFiles() {
+ throw new Error('File mutations are not configured for this fixture.');
+ },
async searchBranches() {
return { refsRevision: 0, candidates: [] };
},
diff --git a/apps/ui/src/protocol-repository-source.ts b/apps/ui/src/protocol-repository-source.ts
index 76399a5..37c77b5 100644
--- a/apps/ui/src/protocol-repository-source.ts
+++ b/apps/ui/src/protocol-repository-source.ts
@@ -234,6 +234,27 @@ export function createProtocolRepositorySource(
if (!response.ok) throw new Error('Native action request failed.');
return nativeActionResultSchema.parse(await response.json());
},
+ async mutateFiles(request) {
+ const command = { ...request } satisfies ProductCommand;
+ const receipt = await submitCommand(
+ fetcher,
+ options.sessionUrl,
+ commandEnvelopeSchema.parse({
+ clientCommandId: createClientCommandId(),
+ command,
+ }),
+ );
+ if (!operationRecoveryAvailable) {
+ throw new Error('File mutation recovery is unavailable.');
+ }
+ const result = await recoverOperation(
+ fetcher,
+ options.sessionUrl,
+ receipt.operationId,
+ );
+ await requestSnapshot();
+ return result;
+ },
async searchBranches(worktreeId, query) {
const response = await protocolPost(
fetcher,
diff --git a/apps/ui/src/repository-overview-model.ts b/apps/ui/src/repository-overview-model.ts
index 11ed3e4..cc99497 100644
--- a/apps/ui/src/repository-overview-model.ts
+++ b/apps/ui/src/repository-overview-model.ts
@@ -105,6 +105,12 @@ export interface RepositoryOverviewSource {
requestNativeAction(
request: NativeActionRequest,
): Promise;
+ mutateFiles(request: {
+ readonly kind: 'stage' | 'unstage';
+ readonly worktreeId: ProtocolWorktree['worktreeId'];
+ readonly expectedWorktreeRevision: number;
+ readonly fileIds: readonly FileId[];
+ }): Promise;
searchBranches(
worktreeId: ProtocolWorktree['worktreeId'],
query: string,
diff --git a/apps/ui/src/repository-store.test.ts b/apps/ui/src/repository-store.test.ts
index 13d9be5..ac8b87c 100644
--- a/apps/ui/src/repository-store.test.ts
+++ b/apps/ui/src/repository-store.test.ts
@@ -1,3 +1,4 @@
+import { fileIdSchema, operationIdSchema } from '@codex-git/protocol';
import { describe, expect, it, vi } from 'vitest';
import { createOverviewFixture } from './overview-fixtures.js';
@@ -79,4 +80,57 @@ describe('RepositoryStore lifecycle', () => {
);
}
});
+
+ it('follows a successful file mutation into its new Change Group', async () => {
+ const fixture = createOverviewFixture('changed-worktree');
+ const before = fixture.source.getSnapshot();
+ if (before.kind !== 'repository') throw new Error('Expected Repository');
+ const worktree = before.snapshot.worktrees[0]!;
+ const changed = worktree.changes.find(({ kind }) => kind === 'change')!;
+ const stagedFileId = fileIdSchema.parse(
+ 'file_0000000000000000000000000000000a',
+ );
+ const source: RepositoryOverviewSource = {
+ ...fixture.source,
+ async mutateFiles() {
+ fixture.publish({
+ kind: 'repository',
+ snapshot: {
+ ...before.snapshot,
+ repositoryRevision: before.snapshot.repositoryRevision + 1,
+ worktrees: [
+ {
+ ...worktree,
+ worktreeRevision: worktree.worktreeRevision + 1,
+ changes: worktree.changes.map((change) =>
+ change.fileId === changed.fileId
+ ? {
+ ...change,
+ fileId: stagedFileId,
+ kind: 'staged_change' as const,
+ baseline: 'head_to_index' as const,
+ }
+ : change,
+ ),
+ },
+ ],
+ },
+ });
+ return {
+ kind: 'succeeded',
+ operationId: operationIdSchema.parse(
+ 'operation_00000000000000000000000000000003',
+ ),
+ result: { kind: 'files', affectedCount: 1 },
+ };
+ },
+ };
+ const store = createRepositoryStore(source);
+ store.selectFile(changed.fileId);
+
+ store.mutateFiles('stage', [changed.fileId]);
+ await Promise.resolve();
+
+ expect(store.getSnapshot().selectedFileId).toBe(stagedFileId);
+ });
});
diff --git a/apps/ui/src/repository-store.ts b/apps/ui/src/repository-store.ts
index 21291dc..66aba23 100644
--- a/apps/ui/src/repository-store.ts
+++ b/apps/ui/src/repository-store.ts
@@ -4,6 +4,7 @@ import type {
FileId,
NativeActionRequest,
NativeActionResult,
+ OperationResult,
RefId,
WorktreeId,
} from '@codex-git/protocol';
@@ -52,6 +53,7 @@ export interface RepositoryStoreSnapshot {
readonly selectionNotice: string | null;
readonly focusRecoveryRevision: number;
readonly branchPicker: BranchPickerState;
+ readonly fileMutationResult: OperationResult | null;
}
export interface RepositoryStore {
@@ -70,6 +72,7 @@ export interface RepositoryStore {
requestNativeAction(
request: NativeActionRequest,
): Promise;
+ mutateFiles(kind: 'stage' | 'unstage', fileIds: readonly FileId[]): void;
openBranchPicker(): void;
closeBranchPicker(): void;
setBranchQuery(query: string): void;
@@ -94,6 +97,10 @@ export function createRepositoryStore(
let focusRecoveryRevision = 0;
let branchPicker: BranchPickerState = { kind: 'closed' };
let branchRequestGeneration = 0;
+ let fileMutationResult: OperationResult | null = null;
+ let fileFollow:
+ | { readonly displayPath: string; readonly kind: 'stage' | 'unstage' }
+ | undefined;
let storeSnapshot = buildSnapshot();
let disposed = false;
@@ -134,10 +141,22 @@ export function createRepositoryStore(
selectedFileId !== null &&
!selected.changes.some(({ fileId }) => fileId === selectedFileId)
) {
- selectedFileId = null;
+ const followed =
+ fileFollow === undefined
+ ? undefined
+ : selected.changes.find(
+ ({ displayPath, kind }) =>
+ displayPath === fileFollow?.displayPath &&
+ (fileFollow.kind === 'stage'
+ ? kind === 'staged_change'
+ : kind === 'change' || kind === 'untracked'),
+ );
+ selectedFileId = followed?.fileId ?? null;
clearDiff();
- selectionNotice =
- 'Changed Files were refreshed; the previous file selection was cleared.';
+ selectionNotice = followed
+ ? `${followed.displayPath} moved to its new Change Group.`
+ : 'Changed Files were refreshed; the previous file selection was cleared.';
+ fileFollow = undefined;
} else {
selectionNotice = null;
}
@@ -239,6 +258,40 @@ export function createRepositoryStore(
message: 'The Repository view is no longer active.',
})
: source.requestNativeAction(request),
+ mutateFiles(kind, fileIds) {
+ if (disposed || fileIds.length === 0 || selectedWorktreeId === null) {
+ return;
+ }
+ const worktree = findWorktree(sourceState, selectedWorktreeId);
+ if (worktree === null) return;
+ const selectedChange = worktree.changes.find(
+ ({ fileId }) => fileId === selectedFileId && fileIds.includes(fileId),
+ );
+ fileFollow =
+ selectedChange === undefined
+ ? undefined
+ : { displayPath: selectedChange.displayPath, kind };
+ void source
+ .mutateFiles({
+ kind,
+ worktreeId: worktree.worktreeId,
+ expectedWorktreeRevision: worktree.worktreeRevision,
+ fileIds,
+ })
+ .then((result) => {
+ if (disposed) return;
+ fileMutationResult = result;
+ emit();
+ })
+ .catch(() => {
+ if (disposed) return;
+ selectionNotice = 'The file mutation could not be submitted.';
+ emit();
+ })
+ .finally(() => {
+ fileFollow = undefined;
+ });
+ },
openBranchPicker() {
if (disposed || selectedWorktreeId === null) return;
void loadBranches('');
@@ -365,6 +418,7 @@ export function createRepositoryStore(
selectionNotice,
focusRecoveryRevision,
branchPicker,
+ fileMutationResult,
};
}
diff --git a/packages/protocol/src/operations.ts b/packages/protocol/src/operations.ts
index 7813de1..881a5a7 100644
--- a/packages/protocol/src/operations.ts
+++ b/packages/protocol/src/operations.ts
@@ -52,7 +52,10 @@ export const operationRecoveryRequestSchema = z.strictObject({
const operationFailureEffectSchema = z.strictObject({
label: z.string().min(1).max(256),
kind: z.literal('failed_known'),
- code: operationFailureCodeSchema,
+ code: z.union([
+ operationFailureCodeSchema,
+ z.enum(['index_locked', 'precondition_failed', 'stale']),
+ ]),
message: messageSchema,
});
diff --git a/packages/repository-engine/src/file-mutation-inspection.ts b/packages/repository-engine/src/file-mutation-inspection.ts
new file mode 100644
index 0000000..8026f62
--- /dev/null
+++ b/packages/repository-engine/src/file-mutation-inspection.ts
@@ -0,0 +1,242 @@
+import { access, realpath, stat } from 'node:fs/promises';
+import type { Stats } from 'node:fs';
+
+import type { RepositorySnapshot } from './repository-publication.js';
+import { fingerprintChangedFileTarget } from './repository-observation.js';
+import { parseWorktreeListPorcelain } from './worktree-porcelain.js';
+
+export type FileMutationTarget =
+ RepositorySnapshot['worktrees'][number]['changes'][number];
+
+export interface FileMutationInspection {
+ readonly commonGitDirectory: string;
+ readonly worktreePath: string;
+ readonly topologyEvidence: string;
+ readonly blockedBy: 'git_lock' | 'index_lock' | 'operation' | null;
+ readonly targetFingerprints: readonly string[];
+}
+
+export type FileMutationInspector = (
+ worktree: RepositorySnapshot['worktrees'][number],
+ targets: readonly FileMutationTarget[],
+ signal: AbortSignal,
+) => Promise;
+
+type GitRunner = (
+ args: readonly string[],
+ allowLargeOutput: boolean,
+ acceptedEmptyExitCode?: 1,
+ signal?: AbortSignal,
+ maximumOutputBytes?: number,
+ input?: Uint8Array,
+) => Promise;
+
+const operationMarkers = [
+ 'rebase-merge',
+ 'rebase-apply',
+ 'MERGE_HEAD',
+ 'CHERRY_PICK_HEAD',
+ 'REVERT_HEAD',
+ 'BISECT_LOG',
+] as const;
+
+export function createFileMutationInspector(
+ runGit: GitRunner,
+): FileMutationInspector {
+ return async (worktree, targets, signal) => {
+ if (worktree.canonicalPath === null) {
+ throw new Error('Cannot inspect an unavailable Worktree.');
+ }
+ const path = worktree.canonicalPath;
+ const paths = targets.flatMap((target) =>
+ target.previousPathBytes === null
+ ? [target.pathBytes]
+ : [target.pathBytes, target.previousPathBytes],
+ );
+ const [identityOutput, gitPathsOutput, registrationsOutput, headOutput] =
+ await Promise.all([
+ runGit(
+ [
+ '-C',
+ path,
+ 'rev-parse',
+ '--path-format=absolute',
+ '--git-common-dir',
+ '--show-toplevel',
+ '--git-dir',
+ ],
+ false,
+ undefined,
+ signal,
+ ),
+ runGit(
+ [
+ '-C',
+ path,
+ 'rev-parse',
+ '--path-format=absolute',
+ '--git-path',
+ 'index',
+ ...operationMarkers.flatMap((marker) => ['--git-path', marker]),
+ ],
+ false,
+ undefined,
+ signal,
+ ),
+ runGit(
+ ['-C', path, 'worktree', 'list', '--porcelain', '-z'],
+ false,
+ undefined,
+ signal,
+ ),
+ runGit(
+ ['-C', path, 'rev-parse', '--verify', '--quiet', 'HEAD'],
+ false,
+ 1,
+ signal,
+ ),
+ ]);
+ const [commonPath, worktreePath, gitDirectory] = lines(identityOutput);
+ const [indexPath, ...markerPaths] = lines(gitPathsOutput);
+ if (
+ commonPath === undefined ||
+ worktreePath === undefined ||
+ gitDirectory === undefined ||
+ indexPath === undefined ||
+ markerPaths.length !== operationMarkers.length
+ ) {
+ throw new Error('Git returned incomplete mutation inspection paths.');
+ }
+ const [canonicalCommon, canonicalWorktree, canonicalGitDirectory] =
+ await Promise.all([
+ realpath(commonPath),
+ realpath(worktreePath),
+ realpath(gitDirectory),
+ ]);
+ const registration = parseWorktreeListPorcelain(registrationsOutput).find(
+ ({ pathBytes }) =>
+ Buffer.from(pathBytes).equals(Buffer.from(canonicalWorktree)),
+ );
+ if (registration === undefined) {
+ throw new Error('The Worktree registration disappeared.');
+ }
+ const [
+ commonMetadata,
+ worktreeMetadata,
+ gitMetadata,
+ indexLocked,
+ markers,
+ ] = await Promise.all([
+ stat(canonicalCommon),
+ stat(canonicalWorktree),
+ stat(canonicalGitDirectory),
+ pathExists(`${indexPath}.lock`),
+ Promise.all(markerPaths.map((markerPath) => pathExists(markerPath))),
+ ]);
+ const indexEvidence = await readIndexEvidence(runGit, path, paths, signal);
+ let evidenceIndex = 0;
+ const headObjectId = decodeLine(headOutput) || null;
+ const targetFingerprints: string[] = [];
+ for (const target of targets) {
+ const targetPathCount = target.previousPathBytes === null ? 1 : 2;
+ targetFingerprints.push(
+ await fingerprintChangedFileTarget(
+ canonicalWorktree,
+ headObjectId,
+ target,
+ indexEvidence.slice(evidenceIndex, evidenceIndex + targetPathCount),
+ ),
+ );
+ evidenceIndex += targetPathCount;
+ }
+ return {
+ commonGitDirectory: canonicalCommon,
+ worktreePath: canonicalWorktree,
+ topologyEvidence: [
+ fileIdentity(commonMetadata),
+ Buffer.from(canonicalWorktree).toString('base64'),
+ fileIdentity(worktreeMetadata),
+ canonicalGitDirectory,
+ fileIdentity(gitMetadata),
+ ].join('\0'),
+ blockedBy: registration.locked
+ ? 'git_lock'
+ : indexLocked
+ ? 'index_lock'
+ : markers.some(Boolean)
+ ? 'operation'
+ : null,
+ targetFingerprints,
+ };
+ };
+}
+
+async function readIndexEvidence(
+ runGit: GitRunner,
+ worktreePath: string,
+ paths: readonly Uint8Array[],
+ signal: AbortSignal,
+): Promise {
+ if (paths.length === 0) return [];
+ const input = Buffer.concat(
+ paths.flatMap((path) => [
+ Buffer.from(':'),
+ Buffer.from(path),
+ Buffer.from([0]),
+ ]),
+ );
+ const output = await runGit(
+ ['-C', worktreePath, 'cat-file', '--batch-check=%(objectname)', '-Z'],
+ false,
+ undefined,
+ signal,
+ undefined,
+ input,
+ );
+ const records = splitNul(output);
+ if (records.length !== paths.length) {
+ throw new Error('Git returned incomplete Index evidence.');
+ }
+ return records.map((record) => {
+ const value = Buffer.from(record).toString();
+ return /^[0-9a-f]{40,64}$/u.test(value) ? `${value} 0` : 'missing';
+ });
+}
+
+function lines(output: Uint8Array): readonly string[] {
+ return Buffer.from(output)
+ .toString()
+ .replace(/\r?\n$/u, '')
+ .split(/\r?\n/u);
+}
+
+function decodeLine(output: Uint8Array): string {
+ return Buffer.from(output)
+ .toString()
+ .replace(/\r?\n$/u, '');
+}
+
+function splitNul(output: Uint8Array): readonly Uint8Array[] {
+ const records: Uint8Array[] = [];
+ let start = 0;
+ for (let index = 0; index < output.length; index += 1) {
+ if (output[index] !== 0) continue;
+ records.push(output.subarray(start, index));
+ start = index + 1;
+ }
+ if (start < output.length) records.push(output.subarray(start));
+ return records;
+}
+
+function fileIdentity(metadata: Stats): string {
+ return `${metadata.dev}:${metadata.ino}:${metadata.birthtimeMs}`;
+}
+
+async function pathExists(path: string): Promise {
+ try {
+ await access(path);
+ return true;
+ } catch {
+ return false;
+ }
+}
diff --git a/packages/repository-engine/src/observation-publication.ts b/packages/repository-engine/src/observation-publication.ts
index 4045a14..78e145e 100644
--- a/packages/repository-engine/src/observation-publication.ts
+++ b/packages/repository-engine/src/observation-publication.ts
@@ -21,10 +21,15 @@ export interface PublishedRepositoryObservation {
readonly worktrees: readonly PublishedObservationWorktree[];
}
+export const privateWorktreeIdentityEvidence = Symbol(
+ 'privateWorktreeIdentityEvidence',
+);
+
export interface PublishedObservationWorktree extends Omit<
DiscoveredWorktree,
- 'canonicalPathBytes'
+ 'canonicalPathBytes' | 'privateIdentityEvidence'
> {
+ readonly [privateWorktreeIdentityEvidence]?: string;
readonly worktreeRevision: number;
readonly freshness: WorktreeFreshness;
readonly index: IndexSnapshot | null;
@@ -110,6 +115,7 @@ export function publishObservedFacts(
> & { readonly changes: readonly ChangedFileObservation[] } = {
worktreeId: worktree.worktreeId,
generation: worktree.generation,
+ [privateWorktreeIdentityEvidence]: worktree.privateIdentityEvidence,
displayPath: worktree.displayPath,
canonicalPath: worktree.canonicalPath,
role: worktree.role,
@@ -129,11 +135,13 @@ export function publishObservedFacts(
const changes =
!changed && prior !== undefined
? prior.changes
- : observedFacts.changes.map((change) => ({
- ...change,
- fileId: requireFileIdIssuer(issueFileId)(),
- nativeTargetId: requireNativeTargetIdIssuer(issueNativeTargetId)(),
- }));
+ : observedFacts.changes.map((change) =>
+ publishedChangedFile(
+ change,
+ requireFileIdIssuer(issueFileId)(),
+ requireNativeTargetIdIssuer(issueNativeTargetId)(),
+ ),
+ );
return {
...candidate,
changes,
@@ -277,10 +285,17 @@ function worktreeEvidence(
index: worktree.index,
status: worktree.status,
changes: worktree.changes.map((change) => {
- if ('fileId' in change) {
- return toObservedChange(change);
- }
- return change;
+ const observed = 'fileId' in change ? toObservedChange(change) : change;
+ return {
+ kind: observed.kind,
+ baseline: observed.baseline,
+ displayPath: observed.displayPath,
+ pathBytes: observed.pathBytes,
+ previousDisplayPath: observed.previousDisplayPath,
+ previousPathBytes: observed.previousPathBytes,
+ workingFilePresent: observed.workingFilePresent,
+ baselineFingerprint: observed.baselineFingerprint,
+ };
}),
});
}
@@ -288,7 +303,7 @@ function worktreeEvidence(
function toObservedChange(
change: PublishedChangedFile,
): ChangedFileObservation {
- return {
+ const observed = {
kind: change.kind,
baseline: change.baseline,
displayPath: change.displayPath,
@@ -296,7 +311,28 @@ function toObservedChange(
previousDisplayPath: change.previousDisplayPath,
previousPathBytes: change.previousPathBytes,
workingFilePresent: change.workingFilePresent,
- } as ChangedFileObservation;
+ } as Omit;
+ Object.defineProperty(observed, 'baselineFingerprint', {
+ enumerable: false,
+ value: change.baselineFingerprint,
+ });
+ return observed as ChangedFileObservation;
+}
+
+function publishedChangedFile(
+ change: ChangedFileObservation,
+ fileId: FileId,
+ nativeTargetId: NativeTargetId,
+): PublishedChangedFile {
+ const published = { ...change, fileId, nativeTargetId } as Omit<
+ PublishedChangedFile,
+ 'baselineFingerprint'
+ >;
+ Object.defineProperty(published, 'baselineFingerprint', {
+ enumerable: false,
+ value: change.baselineFingerprint,
+ });
+ return published as PublishedChangedFile;
}
function requireFileIdIssuer(
diff --git a/packages/repository-engine/src/repository-engine.ts b/packages/repository-engine/src/repository-engine.ts
index ff1b1a4..c32d8a3 100644
--- a/packages/repository-engine/src/repository-engine.ts
+++ b/packages/repository-engine/src/repository-engine.ts
@@ -19,6 +19,7 @@ import {
import { createGitEnvironment } from './git-environment.js';
import { GitReadPolicy } from './git-read-policy.js';
import { readChangedFileDiff } from './change-review.js';
+import { createFileMutationInspector } from './file-mutation-inspection.js';
import { createRepositoryObserver } from './repository-observation.js';
import {
createRepositoryPublicationSession,
@@ -52,6 +53,7 @@ export interface RepositoryDiscovery {
export interface DiscoveredWorktree {
readonly worktreeId: WorktreeId;
readonly generation: WorktreeGeneration;
+ readonly privateIdentityEvidence: string;
readonly displayPath: string;
readonly canonicalPath: AbsolutePath | null;
readonly canonicalPathBytes: Uint8Array;
@@ -234,6 +236,7 @@ export function createRepositoryEngine(): RepositoryEngine {
fetchRemote(resolved.selectedWorktreePath, remoteName, signal),
diff: (worktree, fileId) =>
readChangedFileDiff(worktree, fileId, runGit),
+ inspectFileMutationTargets: createFileMutationInspector(runGit),
runGit,
}),
);
@@ -324,6 +327,7 @@ async function discoverRepository(
return toDiscoveredWorktree(
registration,
worktreeIdentity,
+ `${identity.evidence}\0${worktreeIdentity.evidence}`,
index === 0 ? 'main' : 'linked',
);
});
@@ -610,12 +614,14 @@ function rejectDuplicateAdminIdentities(
function toDiscoveredWorktree(
registration: CanonicalRegistration,
identity: WorktreeIdentityState,
+ privateIdentityEvidence: string,
role: 'main' | 'linked',
): DiscoveredWorktree {
const { record } = registration;
return {
worktreeId: identity.worktreeId,
generation: identity.generation,
+ privateIdentityEvidence,
displayPath: decodeForDisplay(record.pathBytes),
canonicalPath: registration.canonicalPath,
canonicalPathBytes: registration.canonicalPathBytes.slice(),
@@ -718,9 +724,10 @@ function runGit(
acceptedEmptyExitCode?: 1,
signal?: AbortSignal,
maximumOutputBytes?: number,
+ input?: Uint8Array,
): Promise {
return new Promise((resolvePromise, reject) => {
- execFile(
+ const child = execFile(
'git',
[...args],
{
@@ -752,6 +759,7 @@ function runGit(
resolvePromise(stdout);
},
);
+ if (input !== undefined) child.stdin?.end(input);
});
}
diff --git a/packages/repository-engine/src/repository-observation.test.ts b/packages/repository-engine/src/repository-observation.test.ts
index 35d2ca0..46889a1 100644
--- a/packages/repository-engine/src/repository-observation.test.ts
+++ b/packages/repository-engine/src/repository-observation.test.ts
@@ -241,6 +241,7 @@ function fixtureWorktree(path: string, id: string): DiscoveredWorktree {
return {
worktreeId: `worktree_${id}` as DiscoveredWorktree['worktreeId'],
generation: `generation_${id}` as DiscoveredWorktree['generation'],
+ privateIdentityEvidence: `identity-${id}`,
displayPath: path,
canonicalPath: path as DiscoveredWorktree['canonicalPath'],
canonicalPathBytes: Buffer.from(path),
diff --git a/packages/repository-engine/src/repository-observation.ts b/packages/repository-engine/src/repository-observation.ts
index 8dda19a..8cc14aa 100644
--- a/packages/repository-engine/src/repository-observation.ts
+++ b/packages/repository-engine/src/repository-observation.ts
@@ -1,5 +1,6 @@
import { createHash } from 'node:crypto';
-import { access } from 'node:fs/promises';
+import { createReadStream } from 'node:fs';
+import { access, lstat, readlink } from 'node:fs/promises';
import type {
OpaqueIdAuthority,
@@ -21,6 +22,8 @@ import {
import { decodeForDisplay } from './worktree-porcelain.js';
const DEFAULT_GIT_READ_CONCURRENCY = 4;
+const CHANGE_FINGERPRINT_CONCURRENCY = 4;
+const WORKING_FILE_HASH_LIMIT_BYTES = 4 * 1_024 * 1_024;
const MAX_COHERENCE_ATTEMPTS = 3;
const textDecoder = new TextDecoder('utf-8', { fatal: true });
@@ -68,6 +71,7 @@ interface ChangedFileObservationBase<
readonly previousDisplayPath: string | null;
readonly previousPathBytes: Uint8Array | null;
readonly workingFilePresent: boolean;
+ readonly baselineFingerprint?: string;
}
export type InProgressGitOperation =
@@ -345,6 +349,12 @@ async function observeWorktree(
const indexPath = decodeLine(indexPathOutput);
const inProgressOperation =
await detectInProgressOperation(operationPathsOutput);
+ const changes = await fingerprintChangedFiles(
+ worktree.canonicalPath,
+ observed.head,
+ indexOutput,
+ observed.changes,
+ );
return {
kind: 'fresh',
worktreeId: worktree.worktreeId,
@@ -362,7 +372,7 @@ async function observeWorktree(
...observed.status,
inProgressOperation,
},
- changes: observed.changes,
+ changes,
upstream: resolveUpstream(observed.upstream, shared),
};
} catch (error) {
@@ -668,6 +678,184 @@ function changedFile(
} as Extract;
}
+async function fingerprintChangedFiles(
+ worktreePath: string,
+ head: DiscoveredHead,
+ indexOutput: Uint8Array,
+ changes: readonly ChangedFileObservation[],
+): Promise {
+ return mapWithConcurrency(
+ changes,
+ CHANGE_FINGERPRINT_CONCURRENCY,
+ async (change) => {
+ const paths = [change.pathBytes, change.previousPathBytes].filter(
+ (path): path is Uint8Array => path !== null,
+ );
+ return withBaselineFingerprint(
+ { ...change },
+ await fingerprintChangedFileTarget(
+ worktreePath,
+ head.objectId,
+ change,
+ paths.map((path) => indexPathEvidence(indexOutput, path)),
+ ),
+ );
+ },
+ );
+}
+
+export async function fingerprintChangedFileTarget(
+ worktreePath: string,
+ headObjectId: string | null,
+ change: Pick<
+ ChangedFileObservation,
+ 'kind' | 'pathBytes' | 'previousPathBytes'
+ >,
+ indexEvidence: readonly string[],
+): Promise {
+ const paths = [change.pathBytes, change.previousPathBytes].filter(
+ (path): path is Uint8Array => path !== null,
+ );
+ const workingEvidence: string[] = [];
+ for (const path of paths) {
+ workingEvidence.push(await fingerprintWorkingPath(worktreePath, path));
+ }
+ return createHash('sha256')
+ .update(change.kind)
+ .update('\0')
+ .update(headObjectId ?? 'initial')
+ .update('\0')
+ .update(fingerprintIndexEvidence(indexEvidence))
+ .update('\0')
+ .update(workingEvidence.join('\0'))
+ .digest('hex');
+}
+
+async function fingerprintWorkingPath(
+ worktreePath: string,
+ relativePath: Uint8Array,
+): Promise {
+ const path = Buffer.concat([
+ Buffer.from(worktreePath),
+ Buffer.from('/'),
+ Buffer.from(relativePath),
+ ]);
+ let identity = 'unresolved';
+ try {
+ const metadata = await lstat(path);
+ identity = `${metadata.dev}:${metadata.ino}:${metadata.birthtimeMs}:${metadata.mode}:${metadata.size}:${metadata.mtimeMs}:${metadata.ctimeMs}`;
+ if (metadata.isSymbolicLink()) {
+ try {
+ const target = await readlink(path, { encoding: 'buffer' });
+ return createHash('sha256')
+ .update(`symlink\0${identity}\0`)
+ .update(target)
+ .digest('hex');
+ } catch (error) {
+ return `symlink-unreadable:${identity}:${errorCode(error)}`;
+ }
+ }
+ if (metadata.isFile()) {
+ return fingerprintRegularFile(path, identity);
+ }
+ return `non-file:${identity}`;
+ } catch (error) {
+ if (isMissingPath(error)) return 'missing';
+ return `unreadable:${identity}:${errorCode(error)}`;
+ }
+}
+
+async function fingerprintRegularFile(
+ path: Buffer,
+ identity: string,
+): Promise {
+ const hash = createHash('sha256').update(`file\0${identity}\0`);
+ let bytesRead = 0;
+ try {
+ for await (const value of createReadStream(path, {
+ highWaterMark: 64 * 1_024,
+ })) {
+ const chunk = Buffer.from(value);
+ const remaining = WORKING_FILE_HASH_LIMIT_BYTES - bytesRead;
+ if (remaining <= 0) break;
+ const accepted = chunk.subarray(0, remaining);
+ hash.update(accepted);
+ bytesRead += accepted.length;
+ if (accepted.length < chunk.length) break;
+ }
+ return hash.update(`\0sampled:${bytesRead}`).digest('hex');
+ } catch (error) {
+ return `file-unreadable:${identity}:${errorCode(error)}`;
+ }
+}
+
+function indexPathEvidence(indexOutput: Uint8Array, path: Uint8Array): string {
+ const entries: string[] = [];
+ for (const record of splitNul(indexOutput)) {
+ const tab = record.indexOf(0x09);
+ if (tab < 0) continue;
+ const recordPath = record.subarray(tab + 1);
+ if (!Buffer.from(path).equals(Buffer.from(recordPath))) continue;
+ const firstSpace = record.indexOf(0x20);
+ const secondSpace = record.indexOf(0x20, firstSpace + 1);
+ if (firstSpace < 0 || secondSpace < 0) continue;
+ entries.push(Buffer.from(record.subarray(firstSpace + 1, tab)).toString());
+ }
+ return entries.length === 0 ? 'missing' : entries.join(',');
+}
+
+function fingerprintIndexEvidence(evidence: readonly string[]): string {
+ const hash = createHash('sha256');
+ for (const value of evidence) hash.update(value).update('\0');
+ return hash.digest('hex');
+}
+
+async function mapWithConcurrency(
+ values: readonly Input[],
+ concurrency: number,
+ map: (value: Input) => Promise