Merge cb587-snapshot-index-flags-24e236-2

This commit is contained in:
Dai Ha
2026-08-15 15:58:29 +02:00
2 changed files with 139 additions and 8 deletions
@@ -220,20 +220,26 @@ public final class GitWorktrees implements Worktrees {
}
/**
* CB-578 stage C. Stages into a <em>temporary</em> index (never the worktree's real one, which
* the worker may still be writing to), writes that index to a tree, commits the tree on top of
* the worktree's current HEAD, and points {@code refs/wip/<branch>} at the result:
* CB-578 stage C, fixed by CB-587. Stages into a <em>temporary</em> index (never the worktree's
* real one, which the worker may still be writing to) — but that temp index is first <em>seeded</em>
* from the worktree's real one, rather than starting empty:
*
* <pre>
* cp $(git -C worktree rev-parse --git-path index) &lt;temp&gt;
* GIT_INDEX_FILE=&lt;temp&gt; git -C worktree add -A
* tree=$(GIT_INDEX_FILE=&lt;temp&gt; git -C worktree write-tree)
* commit=$(git -C worktree commit-tree $tree -p HEAD -m message)
* git -C worktree update-ref refs/wip/branch $commit
* </pre>
*
* {@code add -A} (never {@code -f}) respects {@code .gitignore} exactly as it would in the real
* index — a gitignored file staying ignored is what keeps secrets and local config out of the
* snapshot's tree. The temporary index file is removed afterwards regardless of outcome.
* A fresh empty index carries none of the real index's {@code --skip-worktree} /
* {@code --assume-unchanged} bits, so {@code add -A} into it stages a skip-worktree file's local
* on-disk content even though {@code git status --porcelain} correctly hides that file (CB-587).
* Seeding from the real index preserves those bits, so {@code add -A} then skips exactly what
* {@code git status} skips. {@code add -A} (never {@code -f}) also still respects
* {@code .gitignore} exactly as it would in the real index — a gitignored file staying ignored is
* what keeps secrets and local config out of the snapshot's tree. The temporary index file is
* removed afterwards regardless of outcome; the worker's real index is never opened for writing.
*/
@Override
public Optional<String> snapshot(String worktreePath, String branch, String message) {
@@ -244,13 +250,18 @@ public final class GitWorktrees implements Worktrees {
Path tempIndex;
try {
tempIndex = Files.createTempFile("bridged-wip-index-", ".tmp");
Files.delete(tempIndex); // git creates it fresh under GIT_INDEX_FILE; a stale empty
// file at that path is otherwise treated as a corrupt index.
} catch (IOException e) {
throw new WorktreeException("cannot create a temporary index for snapshot: " + e.getMessage(), e);
}
Map<String, String> indexEnv = Map.of("GIT_INDEX_FILE", tempIndex.toAbsolutePath().toString());
try {
Path realIndex = resolveRealIndex(worktreePath);
try {
Files.copy(realIndex, tempIndex, StandardCopyOption.REPLACE_EXISTING);
} catch (IOException e) {
throw new WorktreeException("cannot copy the worktree's real index (" + realIndex
+ ") into the temporary snapshot index: " + e.getMessage(), e);
}
exec(indexEnv, "git", "-C", worktreePath, "add", "-A");
String tree = exec(indexEnv, "git", "-C", worktreePath, "write-tree").trim();
String commit = exec("git", "-C", worktreePath, "commit-tree", tree, "-p", "HEAD", "-m", message).trim();
@@ -266,6 +277,28 @@ public final class GitWorktrees implements Worktrees {
}
}
/**
* Resolve the path of {@code worktreePath}'s real index. Never assume {@code <worktree>/.git/index}:
* in a linked worktree {@code .git} is a <em>file</em> pointing at the main repo's
* {@code worktrees/<name>/} directory, and that is where the real per-worktree index lives.
* {@code git rev-parse --git-path index} resolves this correctly for both a linked worktree and
* the main checkout. Throws {@link WorktreeException} — same as every other failure in this
* class — if the command fails or the resolved path does not exist, rather than silently
* snapshotting from an empty index.
*/
private Path resolveRealIndex(String worktreePath) {
String out = exec("git", "-C", worktreePath, "rev-parse", "--git-path", "index").trim();
Path index = Path.of(out);
if (!index.isAbsolute()) {
index = Path.of(worktreePath).resolve(index).normalize();
}
if (!Files.exists(index)) {
throw new WorktreeException("worktree's real index not found at resolved path " + index
+ " (git rev-parse --git-path index reported '" + out + "')");
}
return index;
}
/** Resolve the directory that will hold per-session worktree checkouts. */
private Path resolveRoot(String repoRoot) {
if (configuredRoot != null && !configuredRoot.isBlank()) {
@@ -5,8 +5,10 @@ import org.junit.jupiter.api.io.TempDir;
import java.nio.file.Files;
import java.nio.file.Path;
import java.util.HashSet;
import java.util.List;
import java.util.Optional;
import java.util.Set;
import java.util.concurrent.TimeUnit;
import static org.junit.jupiter.api.Assertions.*;
@@ -97,6 +99,36 @@ class GitWorktreesTest {
return out;
}
/** The set of paths in {@code git diff --name-only from..to} — used to check exactly what a
* snapshot's tree changed relative to its parent, the same shape {@code git status --porcelain}
* reports for the worktree it was taken from. */
private static Set<String> diffNameOnly(Path cwd, String from, String to) throws Exception {
Process p = new ProcessBuilder("git", "-C", cwd.toString(), "diff", "--name-only", from, to)
.redirectErrorStream(true).start();
String out = new String(p.getInputStream().readAllBytes());
assertTrue(p.waitFor(30, TimeUnit.SECONDS), "git diff timed out");
assertEquals(0, p.exitValue(), "git diff " + from + ".." + to + " failed:\n" + out);
Set<String> paths = new HashSet<>();
for (String line : out.split("\\R")) {
if (!line.isBlank()) {
paths.add(line.trim());
}
}
return paths;
}
/** The set of paths a {@code git status --porcelain} listing names, stripping the two-char status
* code prefix each line carries. */
private static Set<String> porcelainPaths(String porcelain) {
Set<String> paths = new HashSet<>();
for (String line : porcelain.split("\\R")) {
if (!line.isBlank()) {
paths.add(line.substring(3).trim());
}
}
return paths;
}
private static String forEachRef(Path cwd, String pattern) throws Exception {
Process p = new ProcessBuilder("git", "-C", cwd.toString(), "for-each-ref", pattern)
.redirectErrorStream(true).start();
@@ -376,4 +408,70 @@ class GitWorktreesTest {
assertTrue(gitWorktrees.snapshot(gone, "some-branch", "msg").isEmpty(),
"a missing worktree has nothing to snapshot, and must not throw");
}
/**
* CB-587, acceptance criteria 1 and 2. A file with a REAL {@code --skip-worktree} bit set in the
* worktree's own index must never enter the snapshot, even though its on-disk content has locally
* diverged from what is committed — that is exactly the divergence {@code --skip-worktree} exists
* to hide from {@code git status}, and a snapshot built from a fresh empty temp index (the bug)
* stages that local content anyway because the fresh index carries none of the real index's flags.
* The snapshot's diff against its parent must list exactly what {@code git status --porcelain}
* reports for the worktree — no more, no less.
*/
@Test
void dirtySnapshotHonoursARealSkipWorktreeBit(@TempDir Path tmp) throws Exception {
Path repo = tmp.resolve("repo");
Files.createDirectories(repo);
git(repo, "init", "-q", "-b", "main");
git(repo, "config", "user.email", "test@example.invalid");
git(repo, "config", "user.name", "Test");
Files.writeString(repo.resolve("protected.cfg"), "committed-value\n");
Files.writeString(repo.resolve("README.md"), "seed\n");
git(repo, "add", "protected.cfg", "README.md");
git(repo, "commit", "-q", "-m", "seed");
GitWorktrees gitWorktrees = new GitWorktrees(tmp.resolve("wts").toString());
String branch = "cb-587-a";
String wt = gitWorktrees.add(repo.toString(), branch, "HEAD");
git(Path.of(wt), "update-index", "--skip-worktree", "protected.cfg");
Files.writeString(Path.of(wt).resolve("protected.cfg"), "locally-diverged-never-commit\n");
Files.writeString(Path.of(wt).resolve("untracked.txt"), "draft that was never added\n");
Files.writeString(Path.of(wt).resolve("README.md"), "edited tracked file\n");
String porcelain = fullStatus(Path.of(wt));
assertFalse(porcelain.contains("protected.cfg"),
"test setup invalid — protected.cfg must not show in git status once skip-worktree is set:\n"
+ porcelain);
Optional<String> ref = gitWorktrees.snapshot(wt, branch, "test snapshot");
assertTrue(ref.isPresent(), "a dirty worktree snapshot returns a commit sha");
Set<String> diffPaths = diffNameOnly(repo, "HEAD", "refs/wip/" + branch);
assertFalse(diffPaths.contains("protected.cfg"),
"a --skip-worktree file's local drift leaked into the snapshot:\n" + diffPaths);
assertEquals(porcelainPaths(porcelain), diffPaths,
"snapshot diff must list exactly what git status --porcelain reports, no more, no less");
}
/**
* CB-587, acceptance criterion 6. If the worktree's real index cannot be resolved/read, snapshot
* must fail loudly (throw) rather than silently falling back to an empty temp index and producing
* a wrong snapshot. SessionManager's caller already catches and WARNs on any exception here — this
* only needs to confirm the failure is not swallowed inside snapshot() itself.
*/
@Test
void snapshotThrowsWhenTheWorktreesRealIndexCannotBeResolved(@TempDir Path tmp) throws Exception {
Path repo = initRepo(tmp.resolve("repo"));
GitWorktrees gitWorktrees = new GitWorktrees(tmp.resolve("wts").toString());
String branch = "cb-587-b";
String wt = gitWorktrees.add(repo.toString(), branch, "HEAD");
// Break git's ability to resolve the worktree's real index by removing the linked worktree's
// `.git` file (which normally points at the main repo's worktrees/<name>/ directory).
Files.delete(Path.of(wt).resolve(".git"));
assertThrows(WorktreeException.class, () -> gitWorktrees.snapshot(wt, branch, "test snapshot"),
"an unresolvable real index must fail loudly, not silently snapshot from an empty index");
}
}