diff --git a/core/src/build-staging/helpers.ts b/core/src/build-staging/helpers.ts index d08855d8d6..40f7ef86e7 100644 --- a/core/src/build-staging/helpers.ts +++ b/core/src/build-staging/helpers.ts @@ -140,8 +140,10 @@ export function cloneFile( } } - // if the target path exists, and it is a symlink, we must remove it first - if (toStats?.isSymbolicLink()) { + // if we are about to copy a symlink, and the target path exists, we must remove it first + // this allows for type changes (e.g. replacing a file with a symlink, then running garden build) + // at this point we know the target is a file or a symlink, so we can do this even if allowDelete=false (copy also overwrites the target) + if (fromStats.isSymbolicLink()) { return remove(to, (removeErr) => { if (removeErr) { return done(removeErr) diff --git a/core/test/unit/src/build-staging/build-staging.ts b/core/test/unit/src/build-staging/build-staging.ts index 74df58f20f..93b66cb2af 100644 --- a/core/test/unit/src/build-staging/build-staging.ts +++ b/core/test/unit/src/build-staging/build-staging.ts @@ -24,7 +24,7 @@ import { BuildTask } from "../../../../src/tasks/build.js" import type { ConfigGraph } from "../../../../src/graph/config-graph.js" import type { BuildAction } from "../../../../src/actions/build.js" import { DOCS_BASE_URL } from "../../../../src/constants.js" -import { lstat, readlink, symlink } from "fs/promises" +import { lstat, readlink, rm, symlink } from "fs/promises" // TODO-G2: rename test cases to match the new graph model semantics @@ -189,6 +189,25 @@ describe("BuildStaging", () => { await assertIdentical(sourceRoot, targetRoot, expectedFiles) }) + it("should allow type changes between symlink and file", async () => { + const sourceRoot = join(tmpPath, "source") + const targetRoot = join(tmpPath, "target") + const file = "foo" + + await ensureDir(sourceRoot) + await populateDirectory(sourceRoot, [file]) + + await sync({ log, sourceRoot, targetRoot, withDelete: false }) + await assertIdentical(sourceRoot, targetRoot, [file]) + + await rm(join(sourceRoot, file)) + await symlink("targetDoesNotMatter", join(sourceRoot, file)) + + // the target now must be replaced with a symlink + await sync({ log, sourceRoot, targetRoot, withDelete: false }) + await assertIdentical(sourceRoot, targetRoot, [file]) + }) + it("throws if source relative path is absolute", async () => { await expectError( () => sync({ log, sourceRoot: tmpPath, targetRoot: tmpPath, sourceRelPath: "/foo", withDelete: false }),