Skip to content

Commit 6b1611d

Browse files
authored
Merge pull request #68 from AgentWorkforce/feat/local-mount-include-git
feat(local-mount): add includeGit option for one-way .git sync
2 parents a3007ac + 3e0236b commit 6b1611d

7 files changed

Lines changed: 212 additions & 17 deletions

File tree

packages/local-mount/CHANGELOG.md

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -6,7 +6,8 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
66

77
## [Unreleased]
88

9-
_No unreleased changes._
9+
### Added
10+
- `MountOptions.includeGit` (also exposed on `launchOnMount`) opts the project's `.git` directory back into the mount with one-way project→mount sync. Git operations work inside the mount; mount-side `.git` mutations stay sandboxed and are discarded on cleanup. Fixes [#66](https://github.com/AgentWorkforce/relayfile/issues/66).
1011

1112
## [0.5.3] - 2026-04-24
1213

packages/local-mount/README.md

Lines changed: 23 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -29,7 +29,7 @@ Behavior:
2929
- Copies regular files into the mount
3030
- Applies ignore rules from `ignoredPatterns`
3131
- Marks read-only matches as mode `0o444`
32-
- Excludes `.git` and `node_modules` by default
32+
- Excludes `.git` and `node_modules` by default. Pass `includeGit: true` to opt the project's `.git` directory back in (see [Including `.git`](#including-git))
3333
- Writes `_MOUNT_README.md` and `.relayfile-local-mount` into the mount
3434
- Skips syncing `_MOUNT_README.md`, `.relayfile-local-mount`, ignored files, read-only files, and symlinks back to the source project
3535

@@ -110,6 +110,28 @@ Conflict and delete rules:
110110
- readonly paths never flow mount→project; project-side edits still flow into the mount (the mount copy is re-chmodded `0o444`)
111111
- `_MOUNT_README.md`, `.relayfile-local-mount`, ignored paths, and excluded directories never cross
112112

113+
## Including `.git`
114+
115+
By default, the project's `.git` directory is excluded from the mount, which means git commands inside the mount fail with `fatal: not a git repository`. Pass `includeGit: true` (on `createMount` or `launchOnMount`) to copy `.git` into the mount with **one-way project→mount sync**:
116+
117+
- `.git` is copied on mount creation, so `git status`, `git log`, `git diff`, `git commit`, etc. all work inside the mount.
118+
- Project-side changes under `.git/**` flow into the mount (e.g. if a teammate's tooling moves `HEAD` while the agent is running).
119+
- Mount-side changes under `.git/**` are **not** synced back to the project. Branches, commits, or refs the agent creates in the mount stay sandboxed and are discarded on cleanup.
120+
121+
If the agent needs its commits to survive, push to a remote from inside the mount. Source files outside `.git` continue to follow the normal bidirectional sync rules.
122+
123+
```ts
124+
launchOnMount({
125+
cli: 'claude',
126+
args: ['--print', 'Inspect the diff and propose a fix.'],
127+
projectDir,
128+
mountDir,
129+
includeGit: true,
130+
});
131+
```
132+
133+
Note that `.git` can be sizable (hundreds of MB on long-lived repos); the initial mount creation copies the whole tree.
134+
113135
## Dotfile semantics
114136

115137
`@relayfile/local-mount` uses glob-style patterns, powered by [`ignore`](https://www.npmjs.com/package/ignore).

packages/local-mount/src/auto-sync.test.ts

Lines changed: 62 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -320,6 +320,68 @@ describe('startAutoSync', () => {
320320
}
321321
});
322322

323+
it('includeGit: project-side .git edits flow into the mount', async () => {
324+
write(path.join(projectDir, '.git/HEAD'), 'ref: refs/heads/main\n');
325+
326+
const handle = createMount(projectDir, mountDir, {
327+
ignoredPatterns: [],
328+
readonlyPatterns: [],
329+
excludeDirs: [],
330+
includeGit: true,
331+
});
332+
333+
const auto = handle.startAutoSync({ debounceMs: 50, scanIntervalMs: 10_000 });
334+
await auto.ready();
335+
try {
336+
writeFileSync(
337+
path.join(projectDir, '.git/HEAD'),
338+
'ref: refs/heads/feature\n',
339+
'utf8'
340+
);
341+
await waitFor(() =>
342+
readFileSync(path.join(handle.mountDir, '.git/HEAD'), 'utf8') ===
343+
'ref: refs/heads/feature\n'
344+
);
345+
} finally {
346+
await auto.stop();
347+
handle.cleanup();
348+
}
349+
});
350+
351+
it('includeGit: mount-side .git edits do NOT flow back to the project', async () => {
352+
write(path.join(projectDir, '.git/HEAD'), 'ref: refs/heads/main\n');
353+
354+
const handle = createMount(projectDir, mountDir, {
355+
ignoredPatterns: [],
356+
readonlyPatterns: [],
357+
excludeDirs: [],
358+
includeGit: true,
359+
});
360+
361+
const auto = handle.startAutoSync({ debounceMs: 50, scanIntervalMs: 10_000 });
362+
await auto.ready();
363+
try {
364+
writeFileSync(
365+
path.join(handle.mountDir, '.git/HEAD'),
366+
'ref: refs/heads/feature\n',
367+
'utf8'
368+
);
369+
writeFileSync(path.join(handle.mountDir, '.git/COMMIT_EDITMSG'), 'wip\n', 'utf8');
370+
371+
// Give autosync time to notice and choose not to propagate.
372+
await new Promise((r) => setTimeout(r, 300));
373+
await auto.reconcile();
374+
375+
expect(readFileSync(path.join(projectDir, '.git/HEAD'), 'utf8')).toBe(
376+
'ref: refs/heads/main\n'
377+
);
378+
expect(existsSync(path.join(projectDir, '.git/COMMIT_EDITMSG'))).toBe(false);
379+
} finally {
380+
await auto.stop();
381+
handle.cleanup();
382+
}
383+
});
384+
323385
it('does not sync the _MOUNT_README.md or marker files', async () => {
324386
const handle = createMount(projectDir, mountDir, {
325387
ignoredPatterns: [],

packages/local-mount/src/auto-sync.ts

Lines changed: 22 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -26,6 +26,13 @@ export interface AutoSyncContext {
2626
*/
2727
isIgnored: (relPosix: string, isDirectory?: boolean) => boolean;
2828
isReadonly: (relPosix: string) => boolean;
29+
/**
30+
* One-way project→mount paths. Project-side changes flow into the mount,
31+
* but mount-side changes never flow back. Unlike readonly, the mount copy
32+
* is left writable so tools (e.g. git) can mutate it locally; those
33+
* mutations are simply discarded on cleanup.
34+
*/
35+
isNoSyncBack: (relPosix: string) => boolean;
2936
isReservedFile: (relPosix: string) => boolean;
3037
}
3138

@@ -323,11 +330,16 @@ function reconcile(
323330
* Resolution rules ("mount wins"):
324331
* - If both sides changed since last sync → mount→project.
325332
* - Only mount changed → mount→project (unless mount-side change is disallowed
326-
* for readonly files; then drop the mount change).
333+
* for readonly / noSyncBack files; then drop the mount change).
327334
* - Only project changed → project→mount.
328335
* - One side missing:
329336
* • Other side changed since last sync → recreate the missing side.
330337
* • Otherwise → propagate the delete.
338+
*
339+
* `readonly` and `noSyncBack` both forbid mount→project. The split exists so
340+
* the chmod 0o444 only fires for true readonly entries (e.g. `.agentreadonly`
341+
* matches), while noSyncBack entries (e.g. `.git/**` when `includeGit: true`)
342+
* stay writable in the mount so tools can mutate them locally.
331343
*/
332344
function syncOneFile(
333345
relPosix: string,
@@ -342,6 +354,7 @@ function syncOneFile(
342354

343355
const prev = state.get(relPosix);
344356
const readonly = ctx.isReadonly(relPosix);
357+
const noSyncBack = readonly || ctx.isNoSyncBack(relPosix);
345358

346359
if (!mountStat && !projectStat) {
347360
state.delete(relPosix);
@@ -359,15 +372,15 @@ function syncOneFile(
359372
return false;
360373
}
361374
// Differ with no history: arbitrary tiebreak → mount wins.
362-
if (readonly) {
363-
// Readonly can't accept mount-side writes; fall back to project→mount.
375+
if (noSyncBack) {
376+
// Mount-side writes never flow back; fall back to project→mount.
364377
return doProjectToMount(relPosix, state, ctx, projectAbs, mountAbs, readonly);
365378
}
366379
return doMountToProject(relPosix, state, ctx, mountAbs, projectAbs);
367380
}
368381
if (mountStat && !projectStat) {
369-
if (readonly) {
370-
// New file in mount with a readonly pattern → cannot sync back.
382+
if (noSyncBack) {
383+
// New file in mount with a no-sync-back pattern → cannot sync back.
371384
return false;
372385
}
373386
return doMountToProject(relPosix, state, ctx, mountAbs, projectAbs);
@@ -389,7 +402,7 @@ function syncOneFile(
389402

390403
if (mountStat && projectStat) {
391404
if (!mountChanged && !projectChanged) return false;
392-
if (mountChanged && !readonly) {
405+
if (mountChanged && !noSyncBack) {
393406
return doMountToProject(relPosix, state, ctx, mountAbs, projectAbs);
394407
}
395408
if (projectChanged) {
@@ -399,7 +412,7 @@ function syncOneFile(
399412
}
400413

401414
if (mountStat && !projectStat) {
402-
if (mountChanged && !readonly) {
415+
if (mountChanged && !noSyncBack) {
403416
return doMountToProject(relPosix, state, ctx, mountAbs, projectAbs);
404417
}
405418
// Project deleted externally and mount hasn't been touched since → mirror.
@@ -411,8 +424,8 @@ function syncOneFile(
411424
return doProjectToMount(relPosix, state, ctx, projectAbs, mountAbs, readonly);
412425
}
413426
// Mount deleted and project hasn't been touched since → mirror to project.
414-
if (readonly) {
415-
// Readonly deletes in mount don't sync back; recreate mount from project.
427+
if (noSyncBack) {
428+
// No-sync-back deletes in mount don't propagate; recreate from project.
416429
return doProjectToMount(relPosix, state, ctx, projectAbs, mountAbs, readonly);
417430
}
418431
return doDeleteProject(relPosix, state, projectAbs);

packages/local-mount/src/launch.ts

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -17,6 +17,12 @@ export interface LaunchOnMountOptions {
1717
readonlyPatterns?: string[];
1818
/** Extra directory names to exclude from the mount on top of defaults. */
1919
excludeDirs?: string[];
20+
/**
21+
* Include the project's `.git` directory inside the mount with one-way
22+
* project→mount sync. Defaults to false. See {@link MountOptions.includeGit}
23+
* for details.
24+
*/
25+
includeGit?: boolean;
2026
/** Extra env vars merged on top of `process.env`. */
2127
env?: NodeJS.ProcessEnv;
2228
/** Optional agent name, used in the _MOUNT_README.md "Agent:" line. */
@@ -66,6 +72,7 @@ export async function launchOnMount(opts: LaunchOnMountOptions): Promise<LaunchO
6672
readonlyPatterns: opts.readonlyPatterns ?? [],
6773
excludeDirs: opts.excludeDirs ?? [],
6874
agentName: opts.agentName,
75+
includeGit: opts.includeGit,
6976
});
7077

7178
let syncedCount = 0;

packages/local-mount/src/mount.test.ts

Lines changed: 53 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -99,6 +99,59 @@ describe('createMount', () => {
9999
handle.cleanup();
100100
});
101101

102+
it('includeGit: copies .git into the mount and leaves it writable', () => {
103+
write(path.join(projectDir, '.git/HEAD'), 'ref: refs/heads/main\n');
104+
write(path.join(projectDir, '.git/refs/heads/main'), 'deadbeef\n');
105+
write(path.join(projectDir, 'src/code.ts'), 'code');
106+
107+
const handle = createMount(projectDir, mountDir, {
108+
ignoredPatterns: [],
109+
readonlyPatterns: [],
110+
excludeDirs: [],
111+
includeGit: true,
112+
});
113+
114+
expect(existsSync(path.join(handle.mountDir, '.git/HEAD'))).toBe(true);
115+
expect(existsSync(path.join(handle.mountDir, '.git/refs/heads/main'))).toBe(true);
116+
117+
// Mount-side .git files must be writable so tools (git itself) can mutate
118+
// them locally — the noSyncBack guard, not 0o444, is what keeps changes
119+
// out of the project.
120+
const headMode = statSync(path.join(handle.mountDir, '.git/HEAD')).mode & 0o777;
121+
expect(headMode).not.toBe(0o444);
122+
123+
handle.cleanup();
124+
});
125+
126+
it('includeGit: syncBack does not propagate .git mount edits to the project', async () => {
127+
write(path.join(projectDir, '.git/HEAD'), 'ref: refs/heads/main\n');
128+
write(path.join(projectDir, 'src/code.ts'), 'code');
129+
130+
const handle = createMount(projectDir, mountDir, {
131+
ignoredPatterns: [],
132+
readonlyPatterns: [],
133+
excludeDirs: [],
134+
includeGit: true,
135+
});
136+
137+
// Simulate a git command in the mount mutating .git internals AND a normal
138+
// source-file edit. Only the source edit should reach the project.
139+
writeFileSync(path.join(handle.mountDir, '.git/HEAD'), 'ref: refs/heads/feature\n', 'utf8');
140+
writeFileSync(path.join(handle.mountDir, '.git/COMMIT_EDITMSG'), 'wip\n', 'utf8');
141+
writeFileSync(path.join(handle.mountDir, 'src/code.ts'), 'edited', 'utf8');
142+
143+
const synced = await handle.syncBack();
144+
145+
expect(synced).toBe(1);
146+
expect(readFileSync(path.join(projectDir, '.git/HEAD'), 'utf8')).toBe(
147+
'ref: refs/heads/main\n'
148+
);
149+
expect(existsSync(path.join(projectDir, '.git/COMMIT_EDITMSG'))).toBe(false);
150+
expect(readFileSync(path.join(projectDir, 'src/code.ts'), 'utf8')).toBe('edited');
151+
152+
handle.cleanup();
153+
});
154+
102155
it('syncBack: writes back writable changes, skips readonly, skips _MOUNT_README.md, returns count', async () => {
103156
write(path.join(projectDir, 'writable.txt'), 'original');
104157
write(path.join(projectDir, 'readonly.txt'), 'original-ro');

packages/local-mount/src/mount.ts

Lines changed: 43 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -30,6 +30,19 @@ export interface MountOptions {
3030
* If omitted, the doc uses a generic "agent" value.
3131
*/
3232
agentName?: string;
33+
/**
34+
* Include the project's `.git` directory inside the mount with one-way
35+
* project→mount sync. Default: false (`.git` is excluded entirely, matching
36+
* historical behavior).
37+
*
38+
* When true:
39+
* - `.git` is copied into the mount on creation, so git commands work inside.
40+
* - Project-side changes under `.git/**` flow into the mount.
41+
* - Mount-side changes under `.git/**` do NOT flow back to the project, so
42+
* commits/branches the agent creates inside the mount stay sandboxed and
43+
* are discarded with the mount on cleanup. Push to a remote to keep them.
44+
*/
45+
includeGit?: boolean;
3346
}
3447

3548
export interface MountHandle {
@@ -59,13 +72,23 @@ export function createMount(
5972
const resolvedMountDir = path.resolve(mountDir);
6073
const readonlyPatterns = [...options.readonlyPatterns];
6174
const ignoredPatterns = [...options.ignoredPatterns];
75+
const includeGit = options.includeGit === true;
6276
const readonlyMatcher = createPathMatcher(readonlyPatterns);
6377
const ignoredMatcher = createPathMatcher(ignoredPatterns);
78+
// `.git` is in DEFAULT_EXCLUDED_DIRS so the mount stays small and git
79+
// operations don't accidentally cross-mutate the host repo. When the caller
80+
// opts in via `includeGit`, drop it from the defaults and instead route it
81+
// through the noSyncBack matcher below so it stays one-way.
82+
const defaultExcludes = includeGit
83+
? DEFAULT_EXCLUDED_DIRS.filter((d) => d !== '.git')
84+
: DEFAULT_EXCLUDED_DIRS;
6485
const excludeSet = new Set(
65-
[...DEFAULT_EXCLUDED_DIRS, ...options.excludeDirs]
86+
[...defaultExcludes, ...options.excludeDirs]
6687
.map((entry) => normalizeRelativePosix(entry).replace(/^\/+|\/+$/g, ''))
6788
.filter(Boolean)
6889
);
90+
const noSyncBackPatterns = includeGit ? ['.git', '.git/**'] : [];
91+
const noSyncBackMatcher = createPathMatcher(noSyncBackPatterns);
6992

7093
// Guard against mountDir === projectDir. We compare both the realpath'd
7194
// project dir and the plain resolved project dir so callers that pass the
@@ -111,6 +134,7 @@ export function createMount(
111134
isExcluded: (relPosix) => isExcludedPath(relPosix, excludeSet),
112135
isIgnored: (relPosix, isDir) => isPathMatched(relPosix, ignoredMatcher, isDir),
113136
isReadonly: (relPosix) => isPathMatched(relPosix, readonlyMatcher),
137+
isNoSyncBack: (relPosix) => isPathMatched(relPosix, noSyncBackMatcher),
114138
isReservedFile: (relPosix) =>
115139
relPosix === MOUNT_README_FILENAME || relPosix === MOUNT_MARKER_FILENAME,
116140
};
@@ -134,7 +158,8 @@ export function createMount(
134158
realMountDir,
135159
realProjectDir,
136160
readonlyMatcher,
137-
ignoredMatcher
161+
ignoredMatcher,
162+
noSyncBackMatcher
138163
);
139164
synced += syncedForFile;
140165

@@ -387,9 +412,16 @@ function syncMountedFileBack(
387412
mountDir: string,
388413
projectDir: string,
389414
readonlyMatcher: Ignore,
390-
ignoredMatcher: Ignore
415+
ignoredMatcher: Ignore,
416+
noSyncBackMatcher: Ignore
391417
): number {
392-
const relative = resolveSyncRelativePath(sourceFile, mountDir, readonlyMatcher, ignoredMatcher);
418+
const relative = resolveSyncRelativePath(
419+
sourceFile,
420+
mountDir,
421+
readonlyMatcher,
422+
ignoredMatcher,
423+
noSyncBackMatcher
424+
);
393425
if (!relative) return 0;
394426

395427
const safeTargetPath = resolveVerifiedSyncTarget(projectDir, relative);
@@ -407,14 +439,19 @@ function resolveSyncRelativePath(
407439
sourceFile: string,
408440
mountDir: string,
409441
readonlyMatcher: Ignore,
410-
ignoredMatcher: Ignore
442+
ignoredMatcher: Ignore,
443+
noSyncBackMatcher: Ignore
411444
): string | null {
412445
const relative = path.relative(mountDir, sourceFile);
413446
if (relative === '' || relative.startsWith('..')) return null;
414447
const relativePosix = normalizeRelativePosix(relative);
415448
if (relativePosix === MOUNT_README_FILENAME) return null;
416449
if (relativePosix === MOUNT_MARKER_FILENAME) return null;
417-
if (isPathMatched(relative, readonlyMatcher) || isPathMatched(relative, ignoredMatcher)) return null;
450+
if (
451+
isPathMatched(relative, readonlyMatcher) ||
452+
isPathMatched(relative, ignoredMatcher) ||
453+
isPathMatched(relative, noSyncBackMatcher)
454+
) return null;
418455

419456
try {
420457
if (lstatSync(sourceFile).isSymbolicLink()) return null;

0 commit comments

Comments
 (0)