When .gsd is a symlink (external state projects), autoCommit silently drops new milestone artifacts because: 1. nativeAddAllWithExclusions falls back to plain `git add -A` (symlink pathspec rejection: "beyond a symbolic link") 2. `.gsd` is in .gitignore, so new .gsd/ files are invisible to git add `git add -f` also fails through symlinks, so this fix uses `git hash-object -w` + `git update-index --add --cacheinfo` to bypass the symlink restriction entirely, staging each milestone artifact by hashing its content and inserting the blob directly into the index. Includes a reproduction test that creates a repo with .gsd as a symlink, adds new files under .gsd/milestones/, and verifies they are staged. Fixes #2104 Co-authored-by: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
parent
5ecf047553
commit
a6f8f77bbc
2 changed files with 120 additions and 2 deletions
|
|
@ -9,8 +9,8 @@
|
|||
*/
|
||||
|
||||
import { execFileSync, execSync } from "node:child_process";
|
||||
import { existsSync, mkdirSync, readFileSync, writeFileSync } from "node:fs";
|
||||
import { join } from "node:path";
|
||||
import { existsSync, lstatSync, mkdirSync, readFileSync, readdirSync, writeFileSync } from "node:fs";
|
||||
import { join, relative } from "node:path";
|
||||
import { gsdRoot } from "./paths.js";
|
||||
import { GIT_NO_PROMPT_ENV } from "./git-constants.js";
|
||||
import { loadEffectiveGSDPreferences } from "./preferences.js";
|
||||
|
|
@ -486,11 +486,80 @@ export class GitServiceImpl {
|
|||
// git add -A already skips it and the exclusions are harmless no-ops.
|
||||
const allExclusions = [...RUNTIME_EXCLUSION_PATHS, ...extraExclusions];
|
||||
nativeAddAllWithExclusions(this.basePath, allExclusions);
|
||||
|
||||
// Force-add .gsd/milestones/ when .gsd is a symlink (#2104).
|
||||
// When .gsd is a symlink (external state projects), ensureGitignore adds
|
||||
// `.gsd` to .gitignore. The nativeAddAllWithExclusions call above falls
|
||||
// back to plain `git add -A` (symlink pathspec rejection), which respects
|
||||
// .gitignore and silently skips new .gsd/milestones/ files.
|
||||
//
|
||||
// `git add -f` also fails with "beyond a symbolic link", so we use
|
||||
// `git hash-object -w` + `git update-index --add --cacheinfo` to bypass
|
||||
// the symlink restriction entirely. This stages each milestone artifact
|
||||
// individually by hashing the file content and updating the index directly.
|
||||
const gsdPath = join(this.basePath, ".gsd");
|
||||
const milestonesDir = join(gsdPath, "milestones");
|
||||
try {
|
||||
if (
|
||||
existsSync(gsdPath) &&
|
||||
lstatSync(gsdPath).isSymbolicLink() &&
|
||||
existsSync(milestonesDir)
|
||||
) {
|
||||
this._forceAddMilestoneArtifacts(milestonesDir);
|
||||
}
|
||||
} catch {
|
||||
// Non-fatal: if force-add fails, the commit proceeds without these files.
|
||||
// This matches existing behavior where milestone artifacts were silently
|
||||
// omitted — but now we at least attempt to include them.
|
||||
}
|
||||
}
|
||||
|
||||
/** Tracks whether runtime file cleanup has run this session. */
|
||||
private _runtimeFilesCleanedUp = false;
|
||||
|
||||
/**
|
||||
* Recursively collect all files under a directory.
|
||||
* Returns paths relative to `basePath` (e.g. ".gsd/milestones/M009/SUMMARY.md").
|
||||
*/
|
||||
private _collectFiles(dir: string): string[] {
|
||||
const files: string[] = [];
|
||||
for (const entry of readdirSync(dir, { withFileTypes: true })) {
|
||||
const full = join(dir, entry.name);
|
||||
if (entry.isDirectory()) {
|
||||
files.push(...this._collectFiles(full));
|
||||
} else if (entry.isFile()) {
|
||||
files.push(relative(this.basePath, full));
|
||||
}
|
||||
}
|
||||
return files;
|
||||
}
|
||||
|
||||
/**
|
||||
* Stage milestone artifacts through a symlinked .gsd directory (#2104).
|
||||
*
|
||||
* `git add` (even with `-f`) refuses to stage files "beyond a symbolic link".
|
||||
* This method bypasses that restriction by hashing each file with
|
||||
* `git hash-object -w` and inserting the blob into the index with
|
||||
* `git update-index --add --cacheinfo 100644 <hash> <path>`.
|
||||
*/
|
||||
private _forceAddMilestoneArtifacts(milestonesDir: string): void {
|
||||
const files = this._collectFiles(milestonesDir);
|
||||
for (const filePath of files) {
|
||||
const hash = execFileSync("git", ["hash-object", "-w", filePath], {
|
||||
cwd: this.basePath,
|
||||
stdio: ["ignore", "pipe", "pipe"],
|
||||
encoding: "utf-8",
|
||||
env: GIT_NO_PROMPT_ENV,
|
||||
}).trim();
|
||||
execFileSync("git", ["update-index", "--add", "--cacheinfo", "100644", hash, filePath], {
|
||||
cwd: this.basePath,
|
||||
stdio: ["ignore", "pipe", "pipe"],
|
||||
encoding: "utf-8",
|
||||
env: GIT_NO_PROMPT_ENV,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Stage files (smart staging) and commit.
|
||||
* Returns the commit message string on success, or null if nothing to commit.
|
||||
|
|
|
|||
|
|
@ -1411,6 +1411,55 @@ async function main(): Promise<void> {
|
|||
rmSync(repo, { recursive: true, force: true });
|
||||
}
|
||||
|
||||
// ─── autoCommit: symlinked .gsd stages new milestone artifacts (#2104) ──
|
||||
|
||||
console.log("\n=== autoCommit: symlinked .gsd stages new milestone artifacts (#2104) ===");
|
||||
|
||||
{
|
||||
// Reproduction: when .gsd is a symlink (external state project),
|
||||
// autoCommit silently fails to stage NEW .gsd/milestones/ files because:
|
||||
// 1. nativeAddAllWithExclusions falls back to plain `git add -A` (symlink)
|
||||
// 2. `.gsd` is in .gitignore → new .gsd/ files are invisible to `git add`
|
||||
// The fix: smartStage() force-adds .gsd/milestones/ after the normal staging.
|
||||
const repo = initTempRepo();
|
||||
|
||||
// Create an external .gsd directory and symlink it into the repo
|
||||
const externalGsd = mkdtempSync(join(tmpdir(), "gsd-external-symlink-"));
|
||||
mkdirSync(join(externalGsd, "milestones", "M009"), { recursive: true });
|
||||
mkdirSync(join(externalGsd, "activity"), { recursive: true });
|
||||
mkdirSync(join(externalGsd, "runtime"), { recursive: true });
|
||||
|
||||
symlinkSync(externalGsd, join(repo, ".gsd"));
|
||||
|
||||
// .gitignore blocks .gsd (as ensureGitignore would do for symlink projects)
|
||||
writeFileSync(join(repo, ".gitignore"), ".gsd\n");
|
||||
run("git add .gitignore && git commit -m 'add gitignore'", repo);
|
||||
|
||||
// Simulate new milestone artifacts created during execution
|
||||
writeFileSync(join(externalGsd, "milestones", "M009", "M009-SUMMARY.md"), "# M009 Summary");
|
||||
writeFileSync(join(externalGsd, "milestones", "M009", "S01-SUMMARY.md"), "# S01 Summary");
|
||||
writeFileSync(join(externalGsd, "milestones", "M009", "T01-VERIFY.json"), '{"passed":true}');
|
||||
|
||||
// Also create a normal source file change
|
||||
createFile(repo, "src/feature.ts", "export const feature = true;");
|
||||
|
||||
const svc = new GitServiceImpl(repo);
|
||||
const msg = svc.autoCommit("complete-milestone", "M009");
|
||||
assertTrue(msg !== null, "symlink autoCommit: commit succeeds");
|
||||
|
||||
const committed = run("git show --name-only HEAD", repo);
|
||||
assertTrue(committed.includes("src/feature.ts"), "symlink autoCommit: source file committed");
|
||||
assertTrue(committed.includes(".gsd/milestones/M009/M009-SUMMARY.md"),
|
||||
"symlink autoCommit: new M009-SUMMARY.md is committed (not silently dropped)");
|
||||
assertTrue(committed.includes(".gsd/milestones/M009/S01-SUMMARY.md"),
|
||||
"symlink autoCommit: new S01-SUMMARY.md is committed");
|
||||
assertTrue(committed.includes(".gsd/milestones/M009/T01-VERIFY.json"),
|
||||
"symlink autoCommit: new T01-VERIFY.json is committed");
|
||||
|
||||
try { rmSync(repo, { recursive: true, force: true }); } catch {}
|
||||
try { rmSync(externalGsd, { recursive: true, force: true }); } catch {}
|
||||
}
|
||||
|
||||
report();
|
||||
}
|
||||
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue