#!/usr/bin/env bash
# PR #34447 "fix(server): No project behind a symlinked scratch folder stays No project" (fixes #34439).
# Claim: already-fixed. #34487 (50952beccc) fixed the same bug on main and closed #34439.
# The script appends focused tests that encode this PR's claimed behavior (its own regression test,
# ported to main's API, plus promotion of a thread recorded under the link spelling). They PASS on
# main and FAIL on the commit just before #34487.
# Requires deps installed (`vp i`) and `gh` authenticated. Run from the root of a clean t3code checkout.
set -euo pipefail

PR=34447
FIX=50952becccb0226dbd91f913f81f416cb6472f60
MPF_TEST=apps/server/src/project/ManagedProjectFolders.test.ts
RL_TEST=apps/server/src/orchestration-v2/runtimeLayer.test.ts
MPF_CASE="prove-34447"
RL_CASE="prove-34447"
LOG_DIR=$(mktemp -d "${TMPDIR:-/tmp}/prove-34447.XXXXXX")

if [ -n "$(git status --porcelain)" ]; then
  echo "Refusing to run: working tree is dirty." >&2
  exit 2
fi
ORIG=$(git symbolic-ref -q --short HEAD || git rev-parse HEAD)
restore() { git reset -q --hard; git checkout -q "$ORIG"; rm -rf "$LOG_DIR"; }
trap 'rc=$?; restore; [ "${PROOF_DONE:-0}" = 1 ] && exit 0; [ "$rc" -ne 0 ] && exit "$rc"; exit 1' EXIT

git fetch -q origin main
MAIN=$(git rev-parse FETCH_HEAD)
git fetch -q origin "pull/$PR/head"
PR_HEAD=$(git rev-parse FETCH_HEAD)
git fetch -q origin "$FIX" 2>/dev/null || true
PRE_FIX=$(git rev-parse "$FIX^1")

fails=0
check() { if "$@"; then echo "PASS  $DESC"; else echo "FAIL  $DESC"; fails=$((fails + 1)); fi; }
summary() { for log in "$@"; do grep -E "^ *(Test Files|Tests) |✓|×" "$log" | sed 's/^/      /' | head -8 || true; done; }

# This PR's regression test (head ee67bfb221), ported to main's API: main advertises `scratchRoot`
# as scratchWorkspaceRoot (the PR added `scratchProjectRoot` for that). Also runs the variant
# without the older duplicate, which is the PR's first commit's scenario.
append_mpf_test() {
  cat >>"$MPF_TEST" <<'TS'

it.effect.each([{ duplicate: false }, { duplicate: true }])(
  "prove-34447: No project at the real path behind a symlinked scratch stays No project (duplicate: $duplicate)",
  ({ duplicate }) =>
    withScratch(({ baseDir }) =>
      Effect.gen(function* () {
        const scratch = yield* ManagedProjectFolders.ManagedProjectFolders;
        const projects = yield* ProjectService.ProjectService;
        const fileSystem = yield* FileSystem.FileSystem;
        const path = yield* Path.Path;
        const root = yield* requireRoot;
        const realRoot = path.join(baseDir, "visible-chats");
        yield* fileSystem.makeDirectory(realRoot);
        yield* fileSystem.symlink(realRoot, root);
        const resolvedRealRoot = yield* fileSystem.realPath(realRoot);
        const projectId = ProjectId.make("project:no-project-real-path");
        yield* projects.create({
          commandId: CommandId.make("no-project-real-path"),
          projectId,
          title: "No project",
          workspaceRoot: resolvedRealRoot,
        });
        if (duplicate) {
          // The empty duplicate older servers created under the configured spelling.
          yield* projects.create({
            commandId: CommandId.make("no-project-duplicate"),
            projectId: ProjectId.make("project:no-project-duplicate"),
            title: "No project",
            workspaceRoot: root,
          });
        }

        assert.isTrue(yield* scratch.isScratchProject(projectId));
        assert.equal((yield* scratch.ensureScratchProject).projectId, projectId);
        assert.deepStrictEqual(yield* scratch.scratchRoot, Option.some(resolvedRealRoot));
        const snapshot = yield* projects.snapshot;
        assert.lengthOf(
          snapshot.projects.filter((project) => project.title === "No project"),
          duplicate ? 2 : 1,
        );
      }),
    ),
);
TS
}

# The PR's isScratchFolder claim: a thread whose folder was recorded under the link spelling can
# still be turned into a project once the scratch root resolves to the link's target.
append_rl_test() {
  cat >>"$RL_TEST" <<'TS'

it.effect("prove-34447: promotes a No project thread recorded under the symlinked scratch spelling", () =>
  Effect.gen(function* () {
    const fileSystem = yield* FileSystem.FileSystem;
    const base = yield* fileSystem.realPath(
      yield* fileSystem.makeTempDirectoryScoped({ prefix: "prove-34447-" }),
    );
    const target = `${base}/visible-chats`;
    const link = `${base}/scratch`;
    const folder = `${link}/2026-01-01-old-thread`;
    yield* fileSystem.makeDirectory(`${target}/2026-01-01-old-thread`, { recursive: true });
    yield* fileSystem.symlink(target, link);
    const scratchProjectId = ProjectId.make("prove-34447-scratch");
    const threadId = ThreadId.make("prove-34447-thread");
    const layer = ScratchThreadPromotion.layer.pipe(
      Layer.provide(
        Layer.mock(ManagedProjectFolders.ManagedProjectFolders)({
          scratchRoot: Effect.succeedSome(target),
          isScratchProject: (projectId) => Effect.succeed(projectId === scratchProjectId),
          namedProjectsRoot: "/projects",
        }),
      ),
      Layer.provide(Layer.mergeAll(NodeServices.layer, NodeCrypto.layer)),
      Layer.provideMerge(layerProjectDeletionTest),
    );
    yield* Effect.gen(function* () {
      const projects = yield* ProjectService.ProjectService;
      const orchestrator = yield* Orchestrator.OrchestratorV2;
      const promotion = yield* ScratchThreadPromotion.ScratchThreadPromotion;
      yield* projects.create({
        commandId: CommandId.make("prove-34447-scratch-create"),
        projectId: scratchProjectId,
        title: "No project",
        workspaceRoot: target,
      });
      yield* orchestrator.dispatch({
        type: "thread.create",
        createdBy: "user",
        creationSource: "web",
        commandId: CommandId.make("prove-34447-thread-create"),
        threadId,
        projectId: scratchProjectId,
        title: "old thread",
        modelSelection,
        runtimeMode: "full-access",
        interactionMode: "default",
        branch: null,
        worktreePath: folder,
      });

      const { projectId } = yield* promotion.promote({ threadId });

      const project = yield* projects.getById(projectId);
      assert.equal(Option.getOrUndefined(project)?.workspaceRoot, folder);
      assert.equal((yield* orchestrator.getThreadShell(threadId))?.projectId, projectId);
    }).pipe(Effect.provide(layer));
  }).pipe(Effect.scoped, Effect.provide(NodeServices.layer)),
);
TS
}

run_mpf() { vp test run "$MPF_TEST" -t "$MPF_CASE" >"$1" 2>&1; }
run_rl() { vp test run "$RL_TEST" -t "$RL_CASE" >"$1" 2>&1; }

DESC="#34487 merge commit ${FIX:0:10} is an ancestor of main ${MAIN:0:10}"
check git merge-base --is-ancestor "$FIX" "$MAIN"

DESC="PR head ${PR_HEAD:0:10} does not contain #34487 (it is a parallel fix, not a rebase of it)"
check bash -c "! git merge-base --is-ancestor '$FIX' '$PR_HEAD'"

DESC="#34487 is MERGED with merge commit ${FIX:0:10}"
check test "$(gh pr view 34487 --repo pingdotgg/t3code --json state,mergeCommit --jq '"\(.state) \(.mergeCommit.oid)"')" = "MERGED $FIX"

DESC="issue #34439 (the issue this PR fixes) is CLOSED as COMPLETED by #34487"
check test "$(gh api graphql -f query='{repository(owner:"pingdotgg",name:"t3code"){issue(number:34439){state stateReason timelineItems(itemTypes:CLOSED_EVENT,last:1){nodes{...on ClosedEvent{closer{...on PullRequest{number}}}}}}}}' \
  --jq '.data.repository.issue | "\(.state) \(.stateReason) \(.timelineItems.nodes[0].closer.number)"')" = "CLOSED COMPLETED 34487"

git checkout -q --detach "$MAIN"
append_mpf_test
append_rl_test
DESC="main: No project stored at the real path behind a symlinked scratch is No project, is reused by ensureScratchProject, is the advertised scratch root, and no extra No project is created (with and without an older duplicate)"
check run_mpf "$LOG_DIR/mpf-main.log"
summary "$LOG_DIR/mpf-main.log"
DESC="main: a No project thread whose folder is recorded under the symlink spelling can be turned into a project"
check run_rl "$LOG_DIR/rl-main.log"
summary "$LOG_DIR/rl-main.log"
git reset -q --hard

git checkout -q --detach "$PRE_FIX"
append_mpf_test
append_rl_test
DESC="pre-#34487 ${PRE_FIX:0:10}: the same No project tests FAIL (scratch checks compare path spellings only)"
check bash -c "! vp test run '$MPF_TEST' -t '$MPF_CASE' >'$LOG_DIR/mpf-pre.log' 2>&1"
summary "$LOG_DIR/mpf-pre.log"
grep -m1 -A1 -E "^AssertionError" "$LOG_DIR/mpf-pre.log" | sed 's/^ */      /' || true
grep -m1 -E "^ *[0-9]+\| +assert\.isTrue\(yield\* scratch\.isScratchProject" "$LOG_DIR/mpf-pre.log" | sed 's/^ */      at: /' || true
DESC="pre-#34487 ${PRE_FIX:0:10}: the same promotion test FAILS (folder check compares dirname to scratchRoot as text)"
check bash -c "! vp test run '$RL_TEST' -t '$RL_CASE' >'$LOG_DIR/rl-pre.log' 2>&1"
summary "$LOG_DIR/rl-pre.log"
grep -m1 -E "Only a No project thread" "$LOG_DIR/rl-pre.log" | sed 's/^ */      /' || true
git reset -q --hard

if [ "$fails" -eq 0 ]; then
  echo "VERDICT: CLOSE (already-fixed) - main has this PR's symlinked-scratch No project behavior via #34487"
  PROOF_DONE=1; exit 0
fi
echo "VERDICT: NOT PROVEN - $fails check(s) failed"
exit 1
