shareGroupRunner) {
this.configuredRoot = configuredRoot;
+ this.group = (group == null || group.isBlank()) ? null : group;
this.afterWorktreeAdded = afterWorktreeAdded == null ? _ -> {} : afterWorktreeAdded;
+ this.shareGroupRunner = shareGroupRunner != null ? shareGroupRunner : this::exec;
}
@Override
@@ -607,6 +648,126 @@ public final class GitWorktrees implements Worktrees {
return deleted;
}
+ /**
+ * {@inheritDoc}
+ *
+ * fleetd #185 stage 3. No-op — no process spawned, nothing logged — when {@link #group} is
+ * null/blank. Otherwise:
+ *
+ * - {@code git -C repoRoot config core.sharedRepository group} so every future write by
+ * either uid stays group-writable;
+ * - a one-time {@code chgrp}/{@code chmod g+rwX} fix-up over the worktree directory and,
+ * under the repo's common git directory, {@code objects}, {@code refs},
+ * {@code logs}, {@code worktrees} and {@code packed-refs} — with setgid
+ * ({@code chmod g+s}) applied only to the directories among them, so files created later
+ * inherit the group;
+ * - one INFO line naming the group and the paths touched.
+ *
+ *
+ * Every path is skipped when it does not exist. {@code .git/logs} is absent in a repo
+ * with {@code core.logAllRefUpdates=false} or one that has had no ref update yet, and
+ * {@code packed-refs} is absent until refs are packed. Passing a missing path to {@code chgrp}
+ * exits non-zero, which would fail every provisioning spawn with a message blaming a
+ * group that is in fact fine.
+ *
+ *
The git directory is resolved, not assumed. {@code /.git} is a
+ * file, not a directory, when the checkout is itself a linked worktree — the very
+ * thing this class creates for every member. {@code git rev-parse --git-common-dir} gives the
+ * real shared store, and it may answer relatively, so it is resolved against {@code repoRoot}.
+ *
+ * The fix-up re-runs on every spawn, by design. {@code core.sharedRepository=group}
+ * governs only what git writes after it is set; the walk is what covers everything
+ * already on disk. It is not redundant work to optimise away — dropping it silently leaves
+ * pre-existing objects unreadable to the member. It costs three walks of the object store per
+ * spawn (about 3000 files in this repo, well under a second, but it grows with the repo).
+ *
+ *
This only fixes up file ownership/permissions on the operator's shared repo so a
+ * different-uid member can write to it — it isolates credentials, not the repository. A member
+ * in the group can still write the operator's git objects and refs.
+ *
+ *
Fails loudly: a missing group, or a {@code chgrp}/{@code chmod} refused because the
+ * operator is not a member of it, becomes a {@link WorktreeException} naming the group — never
+ * a silent skip that leaves a member unable to work with nothing in the log to explain why.
+ */
+ @Override
+ public void shareWithGroup(String repoRoot, String worktreePath) {
+ if (group == null) {
+ return;
+ }
+ List touched = new ArrayList<>();
+ try {
+ shareGroupRunner.apply(new String[]{"git", "-C", repoRoot, "config", "core.sharedRepository", "group"});
+ String commonDir = gitCommonDir(repoRoot);
+ shareGroupPathIfPresent(worktreePath, true, touched);
+ for (String name : List.of("objects", "refs", "logs", "worktrees")) {
+ shareGroupPathIfPresent(commonDir + "/" + name, true, touched);
+ }
+ shareGroupPathIfPresent(commonDir + "/packed-refs", false, touched);
+ } catch (WorktreeException e) {
+ throw new WorktreeException("cannot share worktree with group '" + group + "': "
+ + e.getMessage() + " — the group must exist, and the fleetd operator ("
+ + System.getProperty("user.name") + ") must be a member of it", e);
+ }
+ log.info("worktreeGroup={} shared repoRoot={} worktreePath={} paths={}",
+ group, repoRoot, worktreePath, touched);
+ }
+
+ /**
+ * The repo's common git directory as an absolute path — where {@code objects},
+ * {@code refs} and {@code worktrees} actually live. {@code git rev-parse --git-common-dir}
+ * answers relative to {@code repoRoot} in the ordinary case ({@code .git}) and absolutely for a
+ * linked worktree, so the answer is resolved against {@code repoRoot} either way. Never
+ * hardcode {@code repoRoot + "/.git"}: that is a FILE when the checkout is itself a linked
+ * worktree.
+ */
+ private String gitCommonDir(String repoRoot) {
+ String answer = shareGroupRunner.apply(
+ new String[]{"git", "-C", repoRoot, "rev-parse", "--git-common-dir"});
+ String trimmed = answer == null ? "" : answer.trim();
+ if (trimmed.isEmpty()) {
+ trimmed = ".git";
+ }
+ return Path.of(repoRoot).resolve(trimmed).normalize().toString();
+ }
+
+ /**
+ * {@link #shareGroupPath} when {@code path} exists, recording it in {@code touched}; otherwise
+ * nothing at all. A missing path is normal, not an error — see {@link #shareWithGroup}'s
+ * javadoc for which ones are routinely absent and why passing them to {@code chgrp} would fail
+ * every spawn.
+ */
+ private void shareGroupPathIfPresent(String path, boolean recursive, List touched) {
+ if (!Files.exists(Path.of(path))) {
+ return;
+ }
+ shareGroupPath(path, recursive);
+ touched.add(path);
+ }
+
+ /**
+ * {@code chgrp}/{@code chmod g+rwX} {@code path} to {@link #group}. When {@code recursive},
+ * also walks the directories under {@code path} (including {@code path} itself, when it is a
+ * directory) and sets setgid on each — directories only, per the javadoc on
+ * {@link #shareWithGroup}.
+ */
+ private void shareGroupPath(String path, boolean recursive) {
+ List chgrp = new ArrayList<>(List.of("chgrp"));
+ if (recursive) chgrp.add("-R");
+ chgrp.add(group);
+ chgrp.add(path);
+ shareGroupRunner.apply(chgrp.toArray(new String[0]));
+
+ List chmod = new ArrayList<>(List.of("chmod"));
+ if (recursive) chmod.add("-R");
+ chmod.add("g+rwX");
+ chmod.add(path);
+ shareGroupRunner.apply(chmod.toArray(new String[0]));
+
+ if (recursive) {
+ shareGroupRunner.apply(new String[]{"find", path, "-type", "d", "-exec", "chmod", "g+s", "{}", "+"});
+ }
+ }
+
/**
* Every {@code refs/wip/*} ref (see {@link WipRef}). The committer date is read as a unix
* count of seconds and converted to millis. {@code %00} (NUL) separates the fields because a
diff --git a/fleetd/src/main/java/dev/ltms/fleet/session/SessionManager.java b/fleetd/src/main/java/dev/ltms/fleet/session/SessionManager.java
index c502dbe..e135bb7 100644
--- a/fleetd/src/main/java/dev/ltms/fleet/session/SessionManager.java
+++ b/fleetd/src/main/java/dev/ltms/fleet/session/SessionManager.java
@@ -472,6 +472,10 @@ public final class SessionManager implements TurnListener {
try {
path = worktrees.add(repoRoot, branch, wt.baseRef());
worktrees.overlayParity(repoRoot, path, launcher.parityOverlay(preResolvedProfile));
+ // fleetd #185 stage 3: MUST run after overlayParity, not folded into add() — overlayParity
+ // copies more files into the worktree after add() returns, so sharing the group any earlier
+ // leaves those overlay files operator-owned and read-only for a different-uid member.
+ worktrees.shareWithGroup(repoRoot, path);
handle = launcher.spawn(new SpawnRequest(profile, path, callerCwd, sessionName, resumeSessionId, memberRole));
} catch (RuntimeException e) {
log.warn("spawn failed for profile={} role={} branch={} path={}: {}",
diff --git a/fleetd/src/main/java/dev/ltms/fleet/session/Worktrees.java b/fleetd/src/main/java/dev/ltms/fleet/session/Worktrees.java
index e49e928..2005f3c 100644
--- a/fleetd/src/main/java/dev/ltms/fleet/session/Worktrees.java
+++ b/fleetd/src/main/java/dev/ltms/fleet/session/Worktrees.java
@@ -98,4 +98,21 @@ public interface Worktrees {
/** CB-586: the operator-visible census of {@code refs/wip/*} in one repository. */
record WipRefStats(int count, long costBytes) {
}
+
+ /**
+ * Make {@code repoRoot}'s git store and {@code worktreePath} writable by the configured group
+ * (fleetd #185 stage 3), so a member spawned as a different OS user (see
+ * {@code memberHerdrSocket}) can write its own worktree, its per-worktree git metadata, and
+ * its own commit objects. No-op when no group is configured.
+ *
+ * This isolates credentials, not the repository. A member in the group can
+ * still write the operator's git objects and refs in the shared repo — this only fixes file
+ * ownership/permissions so a different-uid member can work at all, it grants no narrower access
+ * than that.
+ *
+ * @param repoRoot the repository whose git store ({@code .git/objects}, {@code refs},
+ * {@code logs}, {@code worktrees}, {@code packed-refs}) needs sharing
+ * @param worktreePath the linked worktree's own directory
+ */
+ void shareWithGroup(String repoRoot, String worktreePath);
}
diff --git a/fleetd/src/test/java/dev/ltms/fleet/config/FleetConfigTest.java b/fleetd/src/test/java/dev/ltms/fleet/config/FleetConfigTest.java
index 7b90c71..7a4f09f 100644
--- a/fleetd/src/test/java/dev/ltms/fleet/config/FleetConfigTest.java
+++ b/fleetd/src/test/java/dev/ltms/fleet/config/FleetConfigTest.java
@@ -1036,6 +1036,35 @@ class FleetConfigTest {
assertEquals(LeadMailbox.DEFAULT_PREFETCH, noEnv.prefetchOrDefault());
}
+ @Test
+ void absentWorktreeGroupLeavesItNull(@TempDir Path dir) throws Exception {
+ Path f = dir.resolve("no-worktree-group.yaml");
+ Files.writeString(f, "bind:\n port: 8080\n");
+
+ FleetConfig cfg = FleetConfig.load(f);
+ assertNull(cfg.worktreeGroup(), "no worktreeGroup: key → null → GitWorktrees.shareWithGroup is a no-op");
+ }
+
+ @Test
+ void worktreeGroupKeyParses(@TempDir Path dir) throws Exception {
+ Path f = dir.resolve("worktree-group.yaml");
+ Files.writeString(f, "bind:\n port: 8080\nworktreeGroup: fleet-workers\n");
+
+ FleetConfig cfg = FleetConfig.load(f);
+ assertEquals("fleet-workers", cfg.worktreeGroup());
+ }
+
+ @Test
+ void absentWorktreeGroupSurvivesTheBackCompatConstructorChain() {
+ // fleetd #185 stage 3: withDefaults() (and every pre-existing call site) must not silently
+ // drop a live worktreeGroup by routing through a back-compat constructor that defaults it
+ // to null.
+ FleetConfig cfg = new FleetConfig(null, null, null, Map.of(), null, null, null, null, null,
+ null, null, null, null, null, null, null, null, null, null, null, "fleet-workers");
+ assertEquals("fleet-workers", cfg.withDefaults().worktreeGroup(),
+ "withDefaults() must carry a configured worktreeGroup through unchanged");
+ }
+
@Test
void absentPrimaryBlockLeavesPrimaryNull(@TempDir Path dir) throws Exception {
Path f = dir.resolve("no-primary.yaml");
diff --git a/fleetd/src/test/java/dev/ltms/fleet/session/FakeWorktrees.java b/fleetd/src/test/java/dev/ltms/fleet/session/FakeWorktrees.java
index 3f1ac5a..847514f 100644
--- a/fleetd/src/test/java/dev/ltms/fleet/session/FakeWorktrees.java
+++ b/fleetd/src/test/java/dev/ltms/fleet/session/FakeWorktrees.java
@@ -30,12 +30,19 @@ public final class FakeWorktrees implements Worktrees {
public record PruneCall(String repoRoot, long minAgeMillis) {
}
+ public record ShareCall(String repoRoot, String worktreePath) {
+ }
+
private final List addCalls = new CopyOnWriteArrayList<>();
private final List removeCalls = new CopyOnWriteArrayList<>();
private final List overlayCalls = new CopyOnWriteArrayList<>();
private final List repoRootCalls = new CopyOnWriteArrayList<>();
private final List snapshotCalls = new CopyOnWriteArrayList<>();
private final List pruneCalls = new CopyOnWriteArrayList<>();
+ private final List shareCalls = new CopyOnWriteArrayList<>();
+ /** Tags every {@code overlayParity}/{@code shareWithGroup} call in call order, so a test can
+ * pin that sharing runs after the overlay copy (fleetd #185 stage 3). */
+ private final List overlayShareOrder = new CopyOnWriteArrayList<>();
private final Set existingPaths = ConcurrentHashMap.newKeySet();
private final Set trackedPaths = ConcurrentHashMap.newKeySet();
private final AtomicLong snapshotSeq = new AtomicLong();
@@ -136,6 +143,13 @@ public final class FakeWorktrees implements Worktrees {
}
overlayCalls.add(new OverlayCall(repoRoot, worktreePath, List.copyOf(overlay),
List.copyOf(copied), List.copyOf(skipped)));
+ overlayShareOrder.add("overlay:" + worktreePath);
+ }
+
+ @Override
+ public void shareWithGroup(String repoRoot, String worktreePath) {
+ shareCalls.add(new ShareCall(repoRoot, worktreePath));
+ overlayShareOrder.add("share:" + worktreePath);
}
@Override
@@ -203,4 +217,17 @@ public final class FakeWorktrees implements Worktrees {
public SnapshotCall lastSnapshot() {
return snapshotCalls.isEmpty() ? null : snapshotCalls.getLast();
}
+
+ public List shareCalls() {
+ return List.copyOf(shareCalls);
+ }
+
+ public ShareCall lastShare() {
+ return shareCalls.isEmpty() ? null : shareCalls.getLast();
+ }
+
+ /** Call-order tags ({@code "overlay:"}/{@code "share:"}) — see field javadoc. */
+ public List overlayShareOrder() {
+ return List.copyOf(overlayShareOrder);
+ }
}
diff --git a/fleetd/src/test/java/dev/ltms/fleet/session/GitWorktreesTest.java b/fleetd/src/test/java/dev/ltms/fleet/session/GitWorktreesTest.java
index 74e99eb..21f5402 100644
--- a/fleetd/src/test/java/dev/ltms/fleet/session/GitWorktreesTest.java
+++ b/fleetd/src/test/java/dev/ltms/fleet/session/GitWorktreesTest.java
@@ -924,4 +924,179 @@ class GitWorktreesTest {
assertEquals(2, stats.count(), "two snapshot refs are reported");
assertTrue(stats.costBytes() > 0, "the cost of the snapshots is a positive byte count");
}
+
+ /**
+ * fleetd #185 stage 3: a recording {@link java.util.function.Function} test seam stands in for
+ * every process {@link GitWorktrees#shareWithGroup} would run — no real second OS user/group
+ * exists on this host, so these are unit tests against that seam, not a live-group integration
+ * test (out of scope per the ticket).
+ */
+ private static List joined(String[] command) {
+ return List.of(command);
+ }
+
+ /** Remove {@code path} and anything under it. Tolerates an already-absent path. */
+ private static void deleteRecursively(Path path) throws Exception {
+ if (!Files.exists(path)) {
+ return;
+ }
+ if (Files.isDirectory(path)) {
+ try (java.util.stream.Stream children = Files.list(path)) {
+ for (Path child : children.toList()) {
+ deleteRecursively(child);
+ }
+ }
+ }
+ Files.delete(path);
+ }
+
+ /** {@code worktreeGroup} absent ⇒ zero processes spawned and no git config written. */
+ @Test
+ void shareWithGroupIsNoopWhenNoGroupConfigured(@TempDir Path tmp) throws Exception {
+ Path repo = initRepo(tmp.resolve("repo"));
+ List> recorded = new java.util.ArrayList<>();
+ java.util.function.Function recordingRunner = cmd -> {
+ recorded.add(joined(cmd));
+ return "";
+ };
+ GitWorktrees gitWorktrees = new GitWorktrees(tmp.resolve("wts").toString(), null, _ -> {}, recordingRunner);
+
+ gitWorktrees.shareWithGroup(repo.toString(), repo.resolve("some-worktree").toString());
+
+ assertTrue(recorded.isEmpty(), "no group configured must spawn no process at all: " + recorded);
+ }
+
+ /** A configured group runs {@code git config core.sharedRepository group} first, then
+ * chgrp/chmod/setgid over every path {@link GitWorktrees#shareWithGroup} documents. */
+ @Test
+ void shareWithGroupRunsConfigThenChgrpChmodSetgidPerPath(@TempDir Path tmp) throws Exception {
+ Path repo = initRepo(tmp.resolve("repo"));
+ String repoRoot = repo.toString();
+ Path worktree = Files.createDirectories(repo.resolve("some-worktree"));
+ String worktreePath = worktree.toString();
+ Files.createDirectories(repo.resolve(".git/worktrees"));
+ List> recorded = new java.util.ArrayList<>();
+ java.util.function.Function recordingRunner = cmd -> {
+ recorded.add(joined(cmd));
+ // What real git answers for an ordinary (non-linked) checkout: relative to repoRoot.
+ return List.of(cmd).contains("--git-common-dir") ? ".git\n" : "";
+ };
+ GitWorktrees gitWorktrees =
+ new GitWorktrees(tmp.resolve("wts").toString(), "devteam", _ -> {}, recordingRunner);
+
+ gitWorktrees.shareWithGroup(repoRoot, worktreePath);
+
+ assertEquals(List.of("git", "-C", repoRoot, "config", "core.sharedRepository", "group"), recorded.get(0),
+ "core.sharedRepository must be set first, so it keeps working after the one-time fix-up");
+ assertTrue(recorded.contains(List.of("git", "-C", repoRoot, "rev-parse", "--git-common-dir")),
+ "the git dir must be asked for, never hardcoded as /.git — that is a FILE "
+ + "when the checkout is itself a linked worktree: " + recorded);
+
+ for (String dir : List.of(worktreePath, repoRoot + "/.git/objects", repoRoot + "/.git/refs",
+ repoRoot + "/.git/logs", repoRoot + "/.git/worktrees")) {
+ assertTrue(recorded.contains(List.of("chgrp", "-R", "devteam", dir)), "missing chgrp -R for " + dir);
+ assertTrue(recorded.contains(List.of("chmod", "-R", "g+rwX", dir)), "missing chmod -R for " + dir);
+ assertTrue(recorded.contains(List.of("find", dir, "-type", "d", "-exec", "chmod", "g+s", "{}", "+")),
+ "missing setgid find pass for " + dir);
+ }
+ // packed-refs does not exist in a freshly-init'd repo (only git gc / pack-refs creates it) —
+ // tolerated absence, so it must not appear at all: no recursive/-R treatment for a plain file.
+ String packedRefs = repoRoot + "/.git/packed-refs";
+ assertTrue(recorded.stream().noneMatch(c -> c.contains(packedRefs)),
+ "packed-refs is absent here and must be skipped, not chgrp'd: " + recorded);
+ }
+
+ /**
+ * A path that does not exist is skipped, never handed to {@code chgrp}. {@code .git/logs} is
+ * absent whenever {@code core.logAllRefUpdates} is false or no ref has been updated yet, and
+ * {@code chgrp} on a missing path exits non-zero — which would fail EVERY provisioning spawn
+ * with a message blaming a group that is in fact fine.
+ */
+ @Test
+ void shareWithGroupSkipsPathsThatDoNotExist(@TempDir Path tmp) throws Exception {
+ Path repo = initRepo(tmp.resolve("repo"));
+ String repoRoot = repo.toString();
+ deleteRecursively(repo.resolve(".git/logs"));
+ assertFalse(Files.exists(repo.resolve(".git/logs")), "fixture: .git/logs must be gone");
+ List> recorded = new java.util.ArrayList<>();
+ java.util.function.Function recordingRunner = cmd -> {
+ recorded.add(joined(cmd));
+ return List.of(cmd).contains("--git-common-dir") ? ".git\n" : "";
+ };
+ GitWorktrees gitWorktrees =
+ new GitWorktrees(tmp.resolve("wts").toString(), "devteam", _ -> {}, recordingRunner);
+
+ gitWorktrees.shareWithGroup(repoRoot, repo.resolve("no-such-worktree").toString());
+
+ String logs = repoRoot + "/.git/logs";
+ assertTrue(recorded.stream().noneMatch(c -> c.contains(logs)),
+ "a missing .git/logs must be skipped, not chgrp'd: " + recorded);
+ assertTrue(recorded.stream().noneMatch(c -> c.contains(repo.resolve("no-such-worktree").toString())),
+ "a missing worktree path must be skipped too: " + recorded);
+ assertTrue(recorded.contains(List.of("chgrp", "-R", "devteam", repoRoot + "/.git/objects")),
+ "paths that DO exist are still shared: " + recorded);
+ }
+
+ /**
+ * The git store is located by {@code rev-parse --git-common-dir}, not by appending
+ * {@code /.git}. When git answers with an absolute path — what it does for a linked worktree,
+ * where {@code /.git} is a file — every shared path must follow that answer.
+ */
+ @Test
+ void shareWithGroupFollowsAnAbsoluteGitCommonDir(@TempDir Path tmp) throws Exception {
+ Path repo = initRepo(tmp.resolve("repo"));
+ Path realGitDir = repo.resolve(".git");
+ List> recorded = new java.util.ArrayList<>();
+ java.util.function.Function recordingRunner = cmd -> {
+ recorded.add(joined(cmd));
+ return List.of(cmd).contains("--git-common-dir") ? realGitDir + "\n" : "";
+ };
+ GitWorktrees gitWorktrees =
+ new GitWorktrees(tmp.resolve("wts").toString(), "devteam", _ -> {}, recordingRunner);
+
+ gitWorktrees.shareWithGroup(tmp.resolve("some/linked/worktree").toString(),
+ repo.resolve("wt").toString());
+
+ assertTrue(recorded.contains(List.of("chgrp", "-R", "devteam", realGitDir + "/objects")),
+ "objects must be taken from the reported common dir, not /.git: " + recorded);
+ }
+
+ /** {@code packed-refs}, when present, is chgrp/chmod'd but never setgid'd (it is a file, not a dir). */
+ @Test
+ void shareWithGroupIncludesPackedRefsWhenPresent(@TempDir Path tmp) throws Exception {
+ Path repo = initRepo(tmp.resolve("repo"));
+ String repoRoot = repo.toString();
+ Path packedRefsPath = repo.resolve(".git/packed-refs");
+ Files.writeString(packedRefsPath, "");
+ List> recorded = new java.util.ArrayList<>();
+ java.util.function.Function recordingRunner = cmd -> {
+ recorded.add(joined(cmd));
+ return "";
+ };
+ GitWorktrees gitWorktrees =
+ new GitWorktrees(tmp.resolve("wts").toString(), "devteam", _ -> {}, recordingRunner);
+
+ gitWorktrees.shareWithGroup(repoRoot, repo.resolve("some-worktree").toString());
+
+ String packedRefs = packedRefsPath.toString();
+ assertTrue(recorded.contains(List.of("chgrp", "devteam", packedRefs)),
+ "packed-refs must be chgrp'd non-recursively when present: " + recorded);
+ assertTrue(recorded.contains(List.of("chmod", "g+rwX", packedRefs)),
+ "packed-refs must be chmod'd non-recursively when present: " + recorded);
+ assertTrue(recorded.stream().noneMatch(c -> c.contains("find") && c.contains(packedRefs)),
+ "packed-refs (a file) must never get the recursive setgid pass: " + recorded);
+ }
+
+ /** A group that does not exist (or that the operator is not a member of) fails loudly, naming it. */
+ @Test
+ void shareWithGroupThrowsNamingTheGroupWhenChgrpFails(@TempDir Path tmp) throws Exception {
+ Path repo = initRepo(tmp.resolve("repo"));
+ GitWorktrees gitWorktrees = new GitWorktrees(tmp.resolve("wts").toString(), "cb185-nonexistent-group-zz");
+ String wt = new GitWorktrees(tmp.resolve("wts").toString()).add(repo.toString(), "cb-185-share", "HEAD");
+
+ WorktreeException e = assertThrows(WorktreeException.class,
+ () -> gitWorktrees.shareWithGroup(repo.toString(), wt));
+ assertTrue(e.getMessage().contains("cb185-nonexistent-group-zz"),
+ "exception must name the missing/refused group: " + e.getMessage());
+ }
}
diff --git a/fleetd/src/test/java/dev/ltms/fleet/session/SessionManagerTest.java b/fleetd/src/test/java/dev/ltms/fleet/session/SessionManagerTest.java
index 89bfc87..08f44b6 100644
--- a/fleetd/src/test/java/dev/ltms/fleet/session/SessionManagerTest.java
+++ b/fleetd/src/test/java/dev/ltms/fleet/session/SessionManagerTest.java
@@ -148,6 +148,10 @@ class SessionManagerTest {
return 0;
}
+ @Override
+ public void shareWithGroup(String repoRoot, String worktreePath) {
+ }
+
List removeCalls() {
return List.copyOf(removeCalls);
}
diff --git a/fleetd/src/test/java/dev/ltms/fleet/session/WorktreeSessionManagerTest.java b/fleetd/src/test/java/dev/ltms/fleet/session/WorktreeSessionManagerTest.java
index 5e1b0eb..b6656e2 100644
--- a/fleetd/src/test/java/dev/ltms/fleet/session/WorktreeSessionManagerTest.java
+++ b/fleetd/src/test/java/dev/ltms/fleet/session/WorktreeSessionManagerTest.java
@@ -163,6 +163,37 @@ class WorktreeSessionManagerTest {
"tracked copied paths are --skip-worktree'd");
}
+ /**
+ * fleetd #185 stage 3, THE TRAP: {@code overlayParity} copies more files into the worktree
+ * AFTER {@code add} returns, so {@code shareWithGroup} must run after it, not folded into
+ * {@code add()} — otherwise every overlay file lands operator-owned and unwritable for a
+ * different-uid member, with a green test suite hiding it.
+ */
+ @Test
+ void shareWithGroupRunsAfterOverlayParityNotBeforeIt() {
+ FakeHerdr herdr = new FakeHerdr();
+ FakeWorktrees worktrees = new FakeWorktrees().withRepoRoot("/repo").withPrefix("/wt")
+ .track(".envrc");
+ SessionManager sessions = new SessionManager(workerService(herdr), worktrees);
+
+ MemberSession s = sessions.acquire("ltms-local", null, "/caller/proj", null,
+ new WorktreeRequest("cb-185", null));
+
+ assertEquals(1, worktrees.overlayCalls().size(), "overlayParity ran exactly once");
+ assertEquals(1, worktrees.shareCalls().size(), "shareWithGroup ran exactly once");
+ FakeWorktrees.OverlayCall overlay = worktrees.lastOverlay();
+ FakeWorktrees.ShareCall share = worktrees.lastShare();
+ assertEquals(s.worktree(), overlay.worktreePath());
+ assertEquals(s.worktree(), share.worktreePath());
+
+ List order = worktrees.overlayShareOrder();
+ int overlayIndex = order.indexOf("overlay:" + s.worktree());
+ int shareIndex = order.indexOf("share:" + s.worktree());
+ assertTrue(overlayIndex >= 0 && shareIndex >= 0, "both calls must be recorded: " + order);
+ assertTrue(overlayIndex < shareIndex,
+ "shareWithGroup MUST run after overlayParity, not before/inside add(): " + order);
+ }
+
@Test
void releaseRemovesWorktreeButDoesNotDeleteBranch() {
FakeHerdr herdr = new FakeHerdr();