From babbf8db693d49c6e35996934658097dc305ba8b Mon Sep 17 00:00:00 2001 From: skulidropek <66840575+skulidropek@users.noreply.github.com> Date: Sun, 26 Apr 2026 20:02:51 +0000 Subject: [PATCH 1/5] feat: extract session sync cli --- .changeset/tidy-session-sync-tool.md | 6 + .github/workflows/check.yml | 37 + .github/workflows/release.yml | 395 +++++--- bun.lock | 25 +- package.json | 9 +- packages/app/package.json | 11 +- .../app/src/docker-git/cli/parser-options.ts | 20 +- .../docker-git/cli/parser-session-gists.ts | 103 --- .../app/src/docker-git/cli/parser-shared.ts | 2 +- packages/app/src/docker-git/cli/parser.ts | 3 - packages/app/src/docker-git/cli/usage.ts | 10 - .../frontend-lib/core/command-options.ts | 6 - .../docker-git/frontend-lib/core/domain.ts | 8 - .../frontend-lib/core/session-gist-domain.ts | 38 - .../frontend-lib/core/sessions-domain.ts | 3 - .../app/src/docker-git/program-unsupported.ts | 20 - packages/app/src/lib/core/command-options.ts | 6 - .../app/src/lib/core/docker-git-scripts.ts | 8 +- packages/app/src/lib/core/domain.ts | 8 - .../app/src/lib/core/session-gist-domain.ts | 38 - packages/app/src/lib/core/sessions-domain.ts | 3 - .../src/lib/core/templates-entrypoint/git.ts | 12 +- .../lib/core/templates-entrypoint/tasks.ts | 2 +- packages/app/src/lib/core/templates.ts | 3 +- .../app/src/lib/core/templates/dockerfile.ts | 15 +- packages/app/src/lib/shell/files.ts | 54 ++ .../app/src/lib/usecases/session-gists.ts | 94 -- .../parser-session-sync-removal.test.ts | 12 + .../tests/hooks/session-backup-gist.test.ts | 74 -- packages/docker-git-session-sync/package.json | 47 + .../docker-git-session-sync/src/backup.ts | 460 ++++++++++ packages/docker-git-session-sync/src/cli.ts | 263 ++++++ packages/docker-git-session-sync/src/core.ts | 186 ++++ packages/docker-git-session-sync/src/json.ts | 37 + packages/docker-git-session-sync/src/main.ts | 19 + packages/docker-git-session-sync/src/shell.ts | 729 +++++++++++++++ .../docker-git-session-sync/src/snapshots.ts | 191 ++++ packages/docker-git-session-sync/src/types.ts | 92 ++ .../tests/session-files.test.ts | 76 ++ .../docker-git-session-sync/tsconfig.json | 11 + .../docker-git-session-sync/vite.config.ts | 22 + .../docker-git-session-sync/vitest.config.ts | 9 + packages/lib/package.json | 1 + packages/lib/src/core/docker-git-scripts.ts | 8 +- packages/lib/src/core/domain.ts | 8 - packages/lib/src/core/session-gist-domain.ts | 36 - packages/lib/src/core/sessions-domain.ts | 3 - .../lib/src/core/templates-entrypoint/git.ts | 12 +- .../src/core/templates-entrypoint/tasks.ts | 2 +- packages/lib/src/core/templates.ts | 3 +- packages/lib/src/core/templates/dockerfile.ts | 15 +- packages/lib/src/shell/files.ts | 54 ++ packages/lib/src/usecases/session-gists.ts | 92 -- .../tests/core/git-post-push-wrapper.test.ts | 12 +- packages/lib/tests/core/templates.test.ts | 9 +- .../lib/tests/usecases/prepare-files.test.ts | 10 +- pnpm-workspace.yaml | 1 + scripts/e2e/local-package-cli.sh | 17 +- scripts/session-backup-gist.js | 686 -------------- scripts/session-backup-repo.js | 844 ------------------ scripts/session-list-gists.js | 230 ----- 61 files changed, 2664 insertions(+), 2546 deletions(-) create mode 100644 .changeset/tidy-session-sync-tool.md delete mode 100644 packages/app/src/docker-git/cli/parser-session-gists.ts delete mode 100644 packages/app/src/docker-git/frontend-lib/core/session-gist-domain.ts delete mode 100644 packages/app/src/lib/core/session-gist-domain.ts delete mode 100644 packages/app/src/lib/usecases/session-gists.ts create mode 100644 packages/app/tests/docker-git/parser-session-sync-removal.test.ts delete mode 100644 packages/app/tests/hooks/session-backup-gist.test.ts create mode 100644 packages/docker-git-session-sync/package.json create mode 100644 packages/docker-git-session-sync/src/backup.ts create mode 100644 packages/docker-git-session-sync/src/cli.ts create mode 100644 packages/docker-git-session-sync/src/core.ts create mode 100644 packages/docker-git-session-sync/src/json.ts create mode 100644 packages/docker-git-session-sync/src/main.ts create mode 100644 packages/docker-git-session-sync/src/shell.ts create mode 100644 packages/docker-git-session-sync/src/snapshots.ts create mode 100644 packages/docker-git-session-sync/src/types.ts create mode 100644 packages/docker-git-session-sync/tests/session-files.test.ts create mode 100644 packages/docker-git-session-sync/tsconfig.json create mode 100644 packages/docker-git-session-sync/vite.config.ts create mode 100644 packages/docker-git-session-sync/vitest.config.ts delete mode 100644 packages/lib/src/core/session-gist-domain.ts delete mode 100644 packages/lib/src/usecases/session-gists.ts delete mode 100644 scripts/session-backup-gist.js delete mode 100644 scripts/session-backup-repo.js delete mode 100644 scripts/session-list-gists.js diff --git a/.changeset/tidy-session-sync-tool.md b/.changeset/tidy-session-sync-tool.md new file mode 100644 index 00000000..84ae9744 --- /dev/null +++ b/.changeset/tidy-session-sync-tool.md @@ -0,0 +1,6 @@ +--- +"@prover-coder-ai/docker-git-session-sync": patch +"@prover-coder-ai/docker-git": patch +--- + +Extract AI agent session synchronization into a standalone docker-git-session-sync package. diff --git a/.github/workflows/check.yml b/.github/workflows/check.yml index 703d9cd5..0087c81b 100644 --- a/.github/workflows/check.yml +++ b/.github/workflows/check.yml @@ -25,6 +25,13 @@ jobs: uses: ./.github/actions/setup - name: Build (docker-git package) run: bun run --cwd packages/app build + - name: Build (session sync package) + run: | + if [ -f packages/docker-git-session-sync/package.json ]; then + bun run --cwd packages/docker-git-session-sync build + else + echo "packages/docker-git-session-sync is not present; skipping" + fi - name: Build (api) run: bun run --cwd packages/api build @@ -53,6 +60,13 @@ jobs: uses: ./.github/actions/setup - name: Typecheck (app) run: bun run --cwd packages/app check + - name: Typecheck (session sync) + run: | + if [ -f packages/docker-git-session-sync/package.json ]; then + bun run --cwd packages/docker-git-session-sync typecheck + else + echo "packages/docker-git-session-sync is not present; skipping" + fi - name: Typecheck (lib) run: bun run --cwd packages/lib typecheck - name: Typecheck (api) @@ -68,6 +82,14 @@ jobs: uses: ./.github/actions/setup - name: Lint (app) run: bun run --cwd packages/app lint + - name: Lint (session sync) + run: | + if [ -f packages/docker-git-session-sync/package.json ] && \ + bun -e "const pkg=JSON.parse(await Bun.file('packages/docker-git-session-sync/package.json').text()); process.exit(pkg.scripts?.lint ? 0 : 1)"; then + bun run --cwd packages/docker-git-session-sync lint + else + echo "packages/docker-git-session-sync lint script is not present; skipping" + fi - name: Lint (lib) run: bun run --cwd packages/lib lint - name: Lint (api) @@ -83,6 +105,13 @@ jobs: uses: ./.github/actions/setup - name: Test (app) run: bun run --cwd packages/app test + - name: Test (session sync) + run: | + if [ -f packages/docker-git-session-sync/package.json ]; then + bun run --cwd packages/docker-git-session-sync test + else + echo "packages/docker-git-session-sync is not present; skipping" + fi - name: Test (lib) run: bun run --cwd packages/lib test - name: Test (api) @@ -98,6 +127,14 @@ jobs: uses: ./.github/actions/setup - name: Lint Effect-TS (app) run: bun run --cwd packages/app lint:effect + - name: Lint Effect-TS (session sync) + run: | + if [ -f packages/docker-git-session-sync/package.json ] && \ + bun -e "const pkg=JSON.parse(await Bun.file('packages/docker-git-session-sync/package.json').text()); process.exit(pkg.scripts?.['lint:effect'] ? 0 : 1)"; then + bun run --cwd packages/docker-git-session-sync lint:effect + else + echo "packages/docker-git-session-sync lint:effect script is not present; skipping" + fi - name: Lint Effect-TS (lib) run: bun run --cwd packages/lib lint:effect diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 5beed962..c1f7b708 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -16,6 +16,10 @@ jobs: release: if: github.event.workflow_run.conclusion == 'success' runs-on: ubuntu-latest + env: + RELEASE_PACKAGE_PATHS: | + packages/docker-git-session-sync/package.json + packages/app/package.json steps: - uses: actions/checkout@v6 with: @@ -31,17 +35,11 @@ jobs: shell: bash run: | set -euo pipefail - PKG_PATH="packages/app/package.json" - PKG_DIR="$(dirname "$PKG_PATH")" - PKG_NAME="$(bun -e "console.log(JSON.parse(await Bun.file('./${PKG_PATH}').text()).name)")" TMP_DIR="$(mktemp -d)" - LOCAL_PACK_DIR="${TMP_DIR}/local-pack" - REMOTE_PACK_DIR="${TMP_DIR}/remote-pack" - README_DEST="packages/app/README.md" - README_BACKUP="" - README_CREATED="false" - BACKUP_PKG="${PKG_DIR}/.package.json.release.bak" GROUP_COUNT=0 + SHOULD_RELEASE="false" + RELEASE_PACKAGE_NAMES=() + RELEASE_PACKAGE_PATHS_TO_RELEASE=() open_group() { echo "::group::$1" @@ -56,16 +54,6 @@ jobs: } cleanup() { - if [ -f "$BACKUP_PKG" ]; then - cp "$BACKUP_PKG" "$PKG_PATH" || true - rm -f "$BACKUP_PKG" || true - fi - if [ -f "$README_BACKUP" ]; then - cp "$README_BACKUP" "$README_DEST" || true - rm -f "$README_BACKUP" || true - elif [ "$README_CREATED" = "true" ] && [ -f "$README_DEST" ]; then - rm -f "$README_DEST" || true - fi while [ "$GROUP_COUNT" -gt 0 ]; do close_group done @@ -73,116 +61,181 @@ jobs: } trap cleanup EXIT - mkdir -p "$LOCAL_PACK_DIR" "$REMOTE_PACK_DIR" - - open_group "Resolve package metadata" if [ -n "${NPM_TOKEN:-}" ]; then printf '%s\n' "//registry.npmjs.org/:_authToken=${NPM_TOKEN}" > "$HOME/.npmrc" fi - if ! LATEST_VERSION="$(npm view "${PKG_NAME}" version 2>/dev/null)"; then + compare_package() { + local pkg_path="$1" + if [ ! -f "$pkg_path" ]; then + echo "::notice::${pkg_path} not found; skipping package comparison." + return 0 + fi + + local pkg_dir + pkg_dir="$(dirname "$pkg_path")" + local pkg_name + pkg_name="$(bun -e "console.log(JSON.parse(await Bun.file('./${pkg_path}').text()).name)")" + local pkg_tmp_dir="${TMP_DIR}/${pkg_name//@/_}" + local local_pack_dir="${pkg_tmp_dir}/local-pack" + local remote_pack_dir="${pkg_tmp_dir}/remote-pack" + local readme_dest="${pkg_dir}/README.md" + local readme_backup="${pkg_tmp_dir}/README.backup" + local readme_created="false" + local backup_pkg="${pkg_dir}/.package.json.release.bak" + mkdir -p "$local_pack_dir" "$remote_pack_dir" + + cleanup_package() { + if [ -f "$backup_pkg" ]; then + cp "$backup_pkg" "$pkg_path" || true + rm -f "$backup_pkg" || true + fi + if [ -f "$readme_backup" ]; then + cp "$readme_backup" "$readme_dest" || true + rm -f "$readme_backup" || true + elif [ "$readme_created" = "true" ] && [ -f "$readme_dest" ]; then + rm -f "$readme_dest" || true + fi + } + + open_group "Resolve package metadata (${pkg_name})" + local latest_version + if ! latest_version="$(npm view "${pkg_name}" version 2>/dev/null)"; then + close_group + echo "Package ${pkg_name} not found on npm; proceeding with release." + SHOULD_RELEASE="true" + RELEASE_PACKAGE_NAMES+=("$pkg_name") + RELEASE_PACKAGE_PATHS_TO_RELEASE+=("$pkg_path") + cleanup_package + return 0 + fi + echo "Package: ${pkg_name}" + echo "Latest npm version: ${latest_version}" close_group - echo "Package ${PKG_NAME} not found on npm; proceeding with release." - echo "should_release=true" >> "$GITHUB_OUTPUT" - exit 0 - fi - echo "Package: ${PKG_NAME}" - echo "Latest npm version: ${LATEST_VERSION}" - close_group - - open_group "Download remote package" - REMOTE_TARBALL="$(npm pack "${PKG_NAME}@${LATEST_VERSION}" --pack-destination "$REMOTE_PACK_DIR" | tail -n 1)" - REMOTE_TAR_PATH="$REMOTE_PACK_DIR/$REMOTE_TARBALL" - echo "Remote tarball: ${REMOTE_TAR_PATH}" - if [ ! -f "$REMOTE_TAR_PATH" ]; then + + open_group "Download remote package (${pkg_name})" + local remote_tarball + remote_tarball="$(npm pack "${pkg_name}@${latest_version}" --pack-destination "$remote_pack_dir" | tail -n 1)" + local remote_tar_path="$remote_pack_dir/$remote_tarball" + echo "Remote tarball: ${remote_tar_path}" + if [ ! -f "$remote_tar_path" ]; then + close_group + echo "Unable to download ${pkg_name}@${latest_version}; proceeding with release." + SHOULD_RELEASE="true" + RELEASE_PACKAGE_NAMES+=("$pkg_name") + RELEASE_PACKAGE_PATHS_TO_RELEASE+=("$pkg_path") + cleanup_package + return 0 + fi close_group - echo "Unable to download ${PKG_NAME}@${LATEST_VERSION}; proceeding with release." - echo "should_release=true" >> "$GITHUB_OUTPUT" - exit 0 - fi - close_group - open_group "Build current package" - bun run --cwd packages/app build - close_group + open_group "Build current package (${pkg_name})" + bun run --cwd "$pkg_dir" build + close_group - open_group "Prepare package README" - if [ -f "$README_DEST" ]; then - README_BACKUP="${TMP_DIR}/README.backup" - cp "$README_DEST" "$README_BACKUP" - else - README_CREATED="true" - fi - mkdir -p "$(dirname "$README_DEST")" - cp README.md "$README_DEST" - close_group - - open_group "Prune package manifest" - cp "$PKG_PATH" "$BACKUP_PKG" - bun node_modules/@prover-coder-ai/dist-deps-prune/dist/main.js apply \ - --package "$PKG_PATH" \ - --prune-dev true \ - --write - close_group - - open_group "Pack current package" - LOCAL_TAR_PATH="$(cd "$PKG_DIR" && bun pm pack --quiet --ignore-scripts --destination "$LOCAL_PACK_DIR" | tail -n 1 | tr -d '\r')" - echo "Local tarball: ${LOCAL_TAR_PATH}" - if [ ! -f "$LOCAL_TAR_PATH" ]; then + open_group "Prepare package README (${pkg_name})" + if [ -f "$readme_dest" ]; then + cp "$readme_dest" "$readme_backup" + else + readme_created="true" + fi + mkdir -p "$(dirname "$readme_dest")" + cp README.md "$readme_dest" close_group - echo "Unable to pack local ${PKG_NAME}; proceeding with release." - echo "should_release=true" >> "$GITHUB_OUTPUT" - exit 0 - fi - close_group - LOCAL_DIR="${TMP_DIR}/local" - REMOTE_DIR="${TMP_DIR}/remote" - mkdir -p "$LOCAL_DIR" "$REMOTE_DIR" + open_group "Prune package manifest (${pkg_name})" + cp "$pkg_path" "$backup_pkg" + bun node_modules/@prover-coder-ai/dist-deps-prune/dist/main.js apply \ + --package "$pkg_path" \ + --prune-dev true \ + --write + close_group - open_group "Extract tarballs" - tar -xzf "$LOCAL_TAR_PATH" -C "$LOCAL_DIR" - tar -xzf "$REMOTE_TAR_PATH" -C "$REMOTE_DIR" - close_group + open_group "Pack current package (${pkg_name})" + local local_tar_path + local_tar_path="$(cd "$pkg_dir" && bun pm pack --quiet --ignore-scripts --destination "$local_pack_dir" | tail -n 1 | tr -d '\r')" + echo "Local tarball: ${local_tar_path}" + if [ ! -f "$local_tar_path" ]; then + close_group + echo "Unable to pack local ${pkg_name}; proceeding with release." + SHOULD_RELEASE="true" + RELEASE_PACKAGE_NAMES+=("$pkg_name") + RELEASE_PACKAGE_PATHS_TO_RELEASE+=("$pkg_path") + cleanup_package + return 0 + fi + close_group - LOCAL_PKG="${LOCAL_DIR}/package/package.json" - REMOTE_PKG="${REMOTE_DIR}/package/package.json" + local local_dir="${pkg_tmp_dir}/local" + local remote_dir="${pkg_tmp_dir}/remote" + mkdir -p "$local_dir" "$remote_dir" - open_group "Normalize package metadata" - if [ ! -f "$LOCAL_PKG" ] || [ ! -f "$REMOTE_PKG" ]; then + open_group "Extract tarballs (${pkg_name})" + tar -xzf "$local_tar_path" -C "$local_dir" + tar -xzf "$remote_tar_path" -C "$remote_dir" close_group - echo "package.json missing in tarball; proceeding with release." - echo "should_release=true" >> "$GITHUB_OUTPUT" - exit 0 - fi - bun -e "const p=process.argv[1];const sort=(v)=>Array.isArray(v)?v.map(sort):v&&typeof v==='object'?Object.keys(v).sort().reduce((acc,k)=>{acc[k]=sort(v[k]);return acc;},{}):v;const pkg=JSON.parse(await Bun.file(p).text());delete pkg.gitHead;pkg.version='0.0.0';const norm=sort(pkg);await Bun.write(p, JSON.stringify(norm, null, 2)+'\n');" "$LOCAL_PKG" - bun -e "const p=process.argv[1];const sort=(v)=>Array.isArray(v)?v.map(sort):v&&typeof v==='object'?Object.keys(v).sort().reduce((acc,k)=>{acc[k]=sort(v[k]);return acc;},{}):v;const pkg=JSON.parse(await Bun.file(p).text());delete pkg.gitHead;pkg.version='0.0.0';const norm=sort(pkg);await Bun.write(p, JSON.stringify(norm, null, 2)+'\n');" "$REMOTE_PKG" - close_group + local local_pkg="${local_dir}/package/package.json" + local remote_pkg="${remote_dir}/package/package.json" - open_group "Compare package contents" - if diff -qr "$LOCAL_DIR/package" "$REMOTE_DIR/package" >/dev/null 2>&1; then - echo "::notice::No changes compared to ${PKG_NAME}@${LATEST_VERSION}. Skipping release." - echo "should_release=false" >> "$GITHUB_OUTPUT" - else - echo "Package differs from ${PKG_NAME}@${LATEST_VERSION}; proceeding with release." - echo "should_release=true" >> "$GITHUB_OUTPUT" - fi - close_group + open_group "Normalize package metadata (${pkg_name})" + if [ ! -f "$local_pkg" ] || [ ! -f "$remote_pkg" ]; then + close_group + echo "package.json missing in tarball; proceeding with release." + SHOULD_RELEASE="true" + RELEASE_PACKAGE_NAMES+=("$pkg_name") + RELEASE_PACKAGE_PATHS_TO_RELEASE+=("$pkg_path") + cleanup_package + return 0 + fi + + bun -e "const p=process.argv[1];const sort=(v)=>Array.isArray(v)?v.map(sort):v&&typeof v==='object'?Object.keys(v).sort().reduce((acc,k)=>{acc[k]=sort(v[k]);return acc;},{}):v;const pkg=JSON.parse(await Bun.file(p).text());delete pkg.gitHead;pkg.version='0.0.0';const norm=sort(pkg);await Bun.write(p, JSON.stringify(norm, null, 2)+'\n');" "$local_pkg" + bun -e "const p=process.argv[1];const sort=(v)=>Array.isArray(v)?v.map(sort):v&&typeof v==='object'?Object.keys(v).sort().reduce((acc,k)=>{acc[k]=sort(v[k]);return acc;},{}):v;const pkg=JSON.parse(await Bun.file(p).text());delete pkg.gitHead;pkg.version='0.0.0';const norm=sort(pkg);await Bun.write(p, JSON.stringify(norm, null, 2)+'\n');" "$remote_pkg" + close_group + + open_group "Compare package contents (${pkg_name})" + if diff -qr "$local_dir/package" "$remote_dir/package" >/dev/null 2>&1; then + echo "::notice::No changes compared to ${pkg_name}@${latest_version}. Skipping release for this package." + else + echo "Package differs from ${pkg_name}@${latest_version}; proceeding with release." + SHOULD_RELEASE="true" + RELEASE_PACKAGE_NAMES+=("$pkg_name") + RELEASE_PACKAGE_PATHS_TO_RELEASE+=("$pkg_path") + fi + close_group + cleanup_package + } + + while IFS= read -r PKG_PATH; do + [ -n "$PKG_PATH" ] || continue + compare_package "$PKG_PATH" + done <<< "$RELEASE_PACKAGE_PATHS" + + echo "should_release=${SHOULD_RELEASE}" >> "$GITHUB_OUTPUT" + { + echo "release_package_names<> "$GITHUB_OUTPUT" - name: Auto changeset (patch if no changeset exists) if: steps.compare_npm.outputs.should_release != 'false' && github.actor != 'github-actions[bot]' shell: bash + env: + RELEASE_PACKAGE_NAMES: ${{ steps.compare_npm.outputs.release_package_names }} run: | set -euo pipefail mkdir -p .changeset if ! ls .changeset/*.md >/dev/null 2>&1; then - printf '%s\n' \ - '---' \ - '"@prover-coder-ai/docker-git": patch' \ - '---' \ - '' \ - 'chore: automated version bump' \ - > ".changeset/auto-${GITHUB_SHA}.md" + CHANGESET_FILE=".changeset/auto-${GITHUB_SHA}.md" + printf '%s\n' '---' > "$CHANGESET_FILE" + while IFS= read -r PKG_NAME; do + [ -n "$PKG_NAME" ] || continue + printf '"%s": patch\n' "$PKG_NAME" >> "$CHANGESET_FILE" + done <<< "$RELEASE_PACKAGE_NAMES" + printf '%s\n' '---' '' 'chore: automated version bump' >> "$CHANGESET_FILE" fi - name: Version packages if: steps.compare_npm.outputs.should_release != 'false' && github.actor != 'github-actions[bot]' @@ -194,11 +247,37 @@ jobs: id: release_version if: steps.compare_npm.outputs.should_release != 'false' && github.actor != 'github-actions[bot]' shell: bash + env: + RELEASE_PACKAGE_PATHS_TO_RELEASE: ${{ steps.compare_npm.outputs.release_package_paths }} run: | set -euo pipefail - VERSION="$(bun -e "console.log(JSON.parse(await Bun.file('./packages/app/package.json').text()).version)")" + PRIMARY_PKG_PATH="packages/app/package.json" + RELEASE_PKG_PATH="" + while IFS= read -r PKG_PATH; do + [ -n "$PKG_PATH" ] || continue + if [ "$PKG_PATH" = "$PRIMARY_PKG_PATH" ]; then + RELEASE_PKG_PATH="$PKG_PATH" + break + fi + if [ -z "$RELEASE_PKG_PATH" ]; then + RELEASE_PKG_PATH="$PKG_PATH" + fi + done <<< "$RELEASE_PACKAGE_PATHS_TO_RELEASE" + + if [ -z "$RELEASE_PKG_PATH" ]; then + echo "No package selected for release." + exit 1 + fi + + PKG_NAME="$(bun -e "console.log(JSON.parse(await Bun.file('./${RELEASE_PKG_PATH}').text()).name)")" + VERSION="$(bun -e "console.log(JSON.parse(await Bun.file('./${RELEASE_PKG_PATH}').text()).version)")" + PKG_SUFFIX="${PKG_NAME#*/}" echo "version=$VERSION" >> "$GITHUB_OUTPUT" - echo "tag=v${VERSION}" >> "$GITHUB_OUTPUT" + if [ "$RELEASE_PKG_PATH" = "$PRIMARY_PKG_PATH" ]; then + echo "tag=v${VERSION}" >> "$GITHUB_OUTPUT" + else + echo "tag=${PKG_SUFFIX}-v${VERSION}" >> "$GITHUB_OUTPUT" + fi - name: Commit version changes if: steps.compare_npm.outputs.should_release != 'false' && github.actor != 'github-actions[bot]' shell: bash @@ -236,16 +315,29 @@ jobs: - name: Prepare package README if: steps.compare_npm.outputs.should_release != 'false' shell: bash + env: + RELEASE_PACKAGE_PATHS_TO_RELEASE: ${{ steps.compare_npm.outputs.release_package_paths }} run: | set -euo pipefail - mkdir -p packages/app - cp README.md packages/app/README.md + while IFS= read -r PKG_PATH; do + [ -n "$PKG_PATH" ] || continue + [ -f "$PKG_PATH" ] || continue + PKG_DIR="$(dirname "$PKG_PATH")" + mkdir -p "$PKG_DIR" + cp README.md "${PKG_DIR}/README.md" + done <<< "$RELEASE_PACKAGE_PATHS_TO_RELEASE" - name: Build dist if: steps.compare_npm.outputs.should_release != 'false' shell: bash + env: + RELEASE_PACKAGE_PATHS_TO_RELEASE: ${{ steps.compare_npm.outputs.release_package_paths }} run: | set -euo pipefail - bun run --cwd packages/app build + while IFS= read -r PKG_PATH; do + [ -n "$PKG_PATH" ] || continue + [ -f "$PKG_PATH" ] || continue + bun run --cwd "$(dirname "$PKG_PATH")" build + done <<< "$RELEASE_PACKAGE_PATHS_TO_RELEASE" - name: Configure npm auth if: steps.compare_npm.outputs.should_release != 'false' && github.actor != 'github-actions[bot]' shell: bash @@ -261,49 +353,60 @@ jobs: shell: bash env: NPM_CONFIG_TOKEN: ${{ secrets.NPM_TOKEN }} + RELEASE_PACKAGE_PATHS_TO_RELEASE: ${{ steps.compare_npm.outputs.release_package_paths }} run: | set -euo pipefail - PKG_PATH="packages/app/package.json" - PKG_NAME="$(bun -e "console.log(JSON.parse(await Bun.file('./${PKG_PATH}').text()).name)")" - VERSION="$(bun -e "console.log(JSON.parse(await Bun.file('./${PKG_PATH}').text()).version)")" + while IFS= read -r PKG_PATH; do + [ -n "$PKG_PATH" ] || continue + [ -f "$PKG_PATH" ] || continue + PKG_DIR="$(dirname "$PKG_PATH")" + PKG_NAME="$(bun -e "console.log(JSON.parse(await Bun.file('./${PKG_PATH}').text()).name)")" + VERSION="$(bun -e "console.log(JSON.parse(await Bun.file('./${PKG_PATH}').text()).version)")" - if npm view "${PKG_NAME}@${VERSION}" version >/dev/null 2>&1; then - echo "Version ${VERSION} already published; skipping npm publish." - exit 0 - fi + if npm view "${PKG_NAME}@${VERSION}" version >/dev/null 2>&1; then + echo "Version ${PKG_NAME}@${VERSION} already published; skipping npm publish." + continue + fi - bun node_modules/@prover-coder-ai/dist-deps-prune/dist/main.js release \ - --package "${PKG_PATH}" \ - --command "bash -lc 'cd packages/app && bun publish --ignore-scripts --access public'" \ - --silent + bun node_modules/@prover-coder-ai/dist-deps-prune/dist/main.js release \ + --package "${PKG_PATH}" \ + --command "bash -lc 'cd ${PKG_DIR} && bun publish --ignore-scripts --access public'" \ + --silent + done <<< "$RELEASE_PACKAGE_PATHS_TO_RELEASE" - name: Publish to GitHub Packages if: steps.compare_npm.outputs.should_release != 'false' && github.actor != 'github-actions[bot]' shell: bash env: NPM_CONFIG_TOKEN: ${{ secrets.GITHUB_TOKEN }} + RELEASE_PACKAGE_PATHS_TO_RELEASE: ${{ steps.compare_npm.outputs.release_package_paths }} run: | set -euo pipefail OWNER="${{ github.repository_owner }}" OWNER_LOWER="$(printf '%s' "$OWNER" | tr '[:upper:]' '[:lower:]')" - PKG_NAME="$(bun -e "console.log(JSON.parse(await Bun.file('./packages/app/package.json').text()).name)")" - ORIGINAL_PKG_NAME="$PKG_NAME" - PKG_SUFFIX="${PKG_NAME#*/}" - if [ "$PKG_SUFFIX" = "$PKG_NAME" ]; then - GH_PKG="@${OWNER_LOWER}/${PKG_NAME}" - else - GH_PKG="@${OWNER_LOWER}/${PKG_SUFFIX}" - fi - printf '%s\n' "@${OWNER_LOWER}:registry=https://npm.pkg.github.com" >> "$HOME/.npmrc" printf '%s\n' "//npm.pkg.github.com/:_authToken=${{ secrets.GITHUB_TOKEN }}" >> "$HOME/.npmrc" - bun -e "const p='packages/app/package.json';const pkg=JSON.parse(await Bun.file(p).text());pkg.name='${GH_PKG}';await Bun.write(p, JSON.stringify(pkg, null, 2)+'\n');" - bun node_modules/@prover-coder-ai/dist-deps-prune/dist/main.js release \ - --package "packages/app/package.json" \ - --command "bash -lc 'cd packages/app && bun publish --ignore-scripts --registry https://npm.pkg.github.com'" \ - --silent - bun -e "const p='packages/app/package.json';const pkg=JSON.parse(await Bun.file(p).text());pkg.name='${ORIGINAL_PKG_NAME}';await Bun.write(p, JSON.stringify(pkg, null, 2)+'\n');" + while IFS= read -r PKG_PATH; do + [ -n "$PKG_PATH" ] || continue + [ -f "$PKG_PATH" ] || continue + PKG_DIR="$(dirname "$PKG_PATH")" + PKG_NAME="$(bun -e "console.log(JSON.parse(await Bun.file('./${PKG_PATH}').text()).name)")" + ORIGINAL_PKG_NAME="$PKG_NAME" + PKG_SUFFIX="${PKG_NAME#*/}" + if [ "$PKG_SUFFIX" = "$PKG_NAME" ]; then + GH_PKG="@${OWNER_LOWER}/${PKG_NAME}" + else + GH_PKG="@${OWNER_LOWER}/${PKG_SUFFIX}" + fi + + bun -e "const p='${PKG_PATH}';const pkg=JSON.parse(await Bun.file(p).text());pkg.name='${GH_PKG}';await Bun.write(p, JSON.stringify(pkg, null, 2)+'\n');" + bun node_modules/@prover-coder-ai/dist-deps-prune/dist/main.js release \ + --package "${PKG_PATH}" \ + --command "bash -lc 'cd ${PKG_DIR} && bun publish --ignore-scripts --registry https://npm.pkg.github.com'" \ + --silent + bun -e "const p='${PKG_PATH}';const pkg=JSON.parse(await Bun.file(p).text());pkg.name='${ORIGINAL_PKG_NAME}';await Bun.write(p, JSON.stringify(pkg, null, 2)+'\n');" + done <<< "$RELEASE_PACKAGE_PATHS_TO_RELEASE" - name: Create GitHub Release if: steps.compare_npm.outputs.should_release != 'false' && github.actor != 'github-actions[bot]' uses: softprops/action-gh-release@v2 @@ -313,14 +416,20 @@ jobs: token: ${{ secrets.GITHUB_TOKEN }} - name: Print npm package link shell: bash + env: + RELEASE_PACKAGE_PATHS_TO_RELEASE: ${{ steps.compare_npm.outputs.release_package_paths }} run: | set -euo pipefail - PKG_NAME="$(bun -e "console.log(JSON.parse(await Bun.file('./packages/app/package.json').text()).name)")" if [ -n "${{ secrets.NPM_TOKEN }}" ]; then printf '%s\n' "//registry.npmjs.org/:_authToken=${{ secrets.NPM_TOKEN }}" > "$HOME/.npmrc" fi - if LATEST_VERSION="$(npm view "${PKG_NAME}" version 2>/dev/null)"; then - echo "::notice::npm package: https://www.npmjs.com/package/${PKG_NAME}/v/${LATEST_VERSION}" - else - echo "::notice::npm package: https://www.npmjs.com/package/${PKG_NAME}" - fi + while IFS= read -r PKG_PATH; do + [ -n "$PKG_PATH" ] || continue + [ -f "$PKG_PATH" ] || continue + PKG_NAME="$(bun -e "console.log(JSON.parse(await Bun.file('./${PKG_PATH}').text()).name)")" + if LATEST_VERSION="$(npm view "${PKG_NAME}" version 2>/dev/null)"; then + echo "::notice::npm package: https://www.npmjs.com/package/${PKG_NAME}/v/${LATEST_VERSION}" + else + echo "::notice::npm package: https://www.npmjs.com/package/${PKG_NAME}" + fi + done <<< "$RELEASE_PACKAGE_PATHS_TO_RELEASE" diff --git a/bun.lock b/bun.lock index 7ae2fc1b..4ab8950d 100644 --- a/bun.lock +++ b/bun.lock @@ -37,7 +37,7 @@ }, "packages/app": { "name": "@prover-coder-ai/docker-git", - "version": "1.0.77", + "version": "1.0.81", "bin": { "docker-git": "dist/src/docker-git/main.js", }, @@ -56,6 +56,7 @@ "@effect/workflow": "^0.18.0", "@gridland/bun": "0.2.53", "@gridland/web": "0.2.53", + "@prover-coder-ai/docker-git-session-sync": "workspace:*", "effect": "^3.21.0", "react": "^19.2.4", "react-dom": "^19.2.4", @@ -103,6 +104,25 @@ "ws": "^8.20.0", }, }, + "packages/docker-git-session-sync": { + "name": "@prover-coder-ai/docker-git-session-sync", + "version": "1.0.0", + "bin": { + "docker-git-session-sync": "dist/docker-git-session-sync.js", + }, + "dependencies": { + "@effect/platform-node": "^0.106.0", + "effect": "^3.21.0", + }, + "devDependencies": { + "@effect/vitest": "^0.29.0", + "@types/node": "^24.12.0", + "@vitejs/plugin-react": "^6.0.1", + "typescript": "^5.9.3", + "vite": "^8.0.1", + "vitest": "^4.1.0", + }, + }, "packages/lib": { "name": "@effect-template/lib", "version": "1.0.0", @@ -131,6 +151,7 @@ "@eslint/compat": "2.0.3", "@eslint/eslintrc": "3.3.5", "@eslint/js": "10.0.1", + "@prover-coder-ai/docker-git-session-sync": "workspace:*", "@prover-coder-ai/eslint-plugin-suggest-members": "^0.0.25", "@ton-ai-core/vibecode-linter": "^1.0.11", "@types/node": "^24.12.0", @@ -490,6 +511,8 @@ "@prover-coder-ai/docker-git": ["@prover-coder-ai/docker-git@workspace:packages/app"], + "@prover-coder-ai/docker-git-session-sync": ["@prover-coder-ai/docker-git-session-sync@workspace:packages/docker-git-session-sync"], + "@prover-coder-ai/eslint-plugin-suggest-members": ["@prover-coder-ai/eslint-plugin-suggest-members@0.0.25", "", { "dependencies": { "@effect/platform": "0.94.5", "@effect/platform-node": "0.104.1", "@effect/schema": "0.75.5", "@typescript-eslint/utils": "8.55.0", "effect": "3.21.0" }, "peerDependencies": { "eslint": "10.1.0", "typescript": "5.9.3" } }, "sha512-J0oZtIz6IYeXWBgNLXaX2HyzSOcqTsjE+vzs/MQr7SKASvBYsyA7F34dQsh/8GM/kWBuSltkUsfv2RIcM6+t5Q=="], "@rolldown/binding-android-arm64": ["@rolldown/binding-android-arm64@1.0.0-rc.10", "", { "os": "android", "cpu": "arm64" }, "sha512-jOHxwXhxmFKuXztiu1ORieJeTbx5vrTkcOkkkn2d35726+iwhrY1w/+nYY/AGgF12thg33qC3R1LMBF5tHTZHg=="], diff --git a/package.json b/package.json index 3fe47b53..021a5692 100644 --- a/package.json +++ b/package.json @@ -7,17 +7,18 @@ "workspaces": [ "packages/api", "packages/app", + "packages/docker-git-session-sync", "packages/lib" ], "scripts": { "setup:pre-commit-hook": "bun scripts/setup-pre-commit-hook.js", - "build": "bun run --filter @prover-coder-ai/docker-git build", + "build": "bun run --filter @prover-coder-ai/docker-git-session-sync build && bun run --filter @prover-coder-ai/docker-git build", "api:build": "bun run --filter @effect-template/api build", "api:start": "bun run --filter @effect-template/api start", "api:dev": "bun run --filter @effect-template/api dev", "api:test": "bun run --filter @effect-template/api test", "api:typecheck": "bun run --filter @effect-template/api typecheck", - "check": "bun run --filter @prover-coder-ai/docker-git check && bun run --filter @effect-template/lib typecheck", + "check": "bun run --filter @prover-coder-ai/docker-git-session-sync check && bun run --filter @prover-coder-ai/docker-git check && bun run --filter @effect-template/lib typecheck", "check:dist-deps-prune": "bun node_modules/@prover-coder-ai/dist-deps-prune/dist/main.js scan --package ./packages/app/package.json --prune-dev true --silent", "changeset": "changeset", "changeset-publish": "bun -e \"if (!process.env.NPM_TOKEN) { console.log('Skipping publish: NPM_TOKEN is not set'); process.exit(0); }\" && changeset publish", @@ -39,8 +40,8 @@ "lint": "bun run --filter @prover-coder-ai/docker-git lint && bun run --filter @effect-template/lib lint", "lint:tests": "bun run --filter @prover-coder-ai/docker-git lint:tests", "lint:effect": "bun run --filter @prover-coder-ai/docker-git lint:effect && bun run --filter @effect-template/lib lint:effect", - "test": "bun run --filter @prover-coder-ai/docker-git test && bun run --filter @effect-template/lib test", - "typecheck": "bun run --filter @prover-coder-ai/docker-git typecheck && bun run --filter @effect-template/lib typecheck", + "test": "bun run --filter @prover-coder-ai/docker-git-session-sync test && bun run --filter @prover-coder-ai/docker-git test && bun run --filter @effect-template/lib test", + "typecheck": "bun run --filter @prover-coder-ai/docker-git-session-sync typecheck && bun run --filter @prover-coder-ai/docker-git typecheck && bun run --filter @effect-template/lib typecheck", "start": "bash -lc 'bun run --cwd packages/app build:docker-git && bun ./packages/app/dist/src/docker-git/main.js \"$@\"' --" }, "devDependencies": { diff --git a/packages/app/package.json b/packages/app/package.json index e01f6afb..9768a514 100644 --- a/packages/app/package.json +++ b/packages/app/package.json @@ -13,7 +13,7 @@ "doc": "doc" }, "scripts": { - "prebuild": "bun run --cwd ../lib build", + "prebuild": "bun run --cwd ../docker-git-session-sync build && bun run --cwd ../lib build", "build": "bun run build:app && bun run build:docker-git", "build:app": "vite build --ssr src/app/main.ts", "build:web": "vite build --config vite.web.config.ts", @@ -21,11 +21,11 @@ "dev": "vite build --watch --ssr src/app/main.ts", "dev:web": "vite --config vite.web.config.ts", "serve:web": "bun scripts/serve-dist-web.mjs", - "prelint": "bun run --cwd ../lib build", + "prelint": "bun run --cwd ../docker-git-session-sync build && bun run --cwd ../lib build", "lint": "NODE_OPTIONS=--max-old-space-size=4096 PATH=../../scripts:$PATH vibecode-linter src/", "lint:tests": "NODE_OPTIONS=--max-old-space-size=4096 PATH=../../scripts:$PATH vibecode-linter tests/", "lint:effect": "NODE_OPTIONS=--max-old-space-size=4096 PATH=../../scripts:$PATH eslint --config eslint.effect-ts-check.config.mjs .", - "prebuild:docker-git": "bun install --cwd ../.. && bun run --cwd ../lib build", + "prebuild:docker-git": "bun install --cwd ../.. && bun run --cwd ../docker-git-session-sync build && bun run --cwd ../lib build", "build:docker-git": "vite build --config vite.docker-git.config.ts", "check": "bun run typecheck", "clone": "bash -lc 'bun run build:docker-git && bun dist/src/docker-git/main.js clone \"$@\"' --", @@ -34,9 +34,9 @@ "list": "bash -lc 'bun run build:docker-git && bun dist/src/docker-git/main.js ps \"$@\"' --", "preview:web": "vite preview --config vite.web.config.ts", "start": "bash -lc 'bun run build:docker-git && bun dist/src/docker-git/main.js \"$@\"' --", - "pretest": "bun run --cwd ../lib build", + "pretest": "bun run --cwd ../docker-git-session-sync build && bun run --cwd ../lib build", "test": "bun run lint:tests && vitest run", - "pretypecheck": "bun run --cwd ../lib build", + "pretypecheck": "bun run --cwd ../docker-git-session-sync build && bun run --cwd ../lib build", "typecheck": "tsc --noEmit" }, "repository": { @@ -61,6 +61,7 @@ "homepage": "https://github.com/ProverCoderAI/docker-git#readme", "packageManager": "bun@1.3.11", "dependencies": { + "@prover-coder-ai/docker-git-session-sync": "workspace:*", "@effect/cli": "^0.75.0", "@effect/cluster": "^0.58.0", "@effect/experimental": "^0.60.0", diff --git a/packages/app/src/docker-git/cli/parser-options.ts b/packages/app/src/docker-git/cli/parser-options.ts index 2835726c..a3dd9ab3 100644 --- a/packages/app/src/docker-git/cli/parser-options.ts +++ b/packages/app/src/docker-git/cli/parser-options.ts @@ -37,10 +37,6 @@ interface ValueOptionSpec { | "projectDir" | "lines" | "agentAutoMode" - | "prNumber" - | "repo" - | "limit" - | "output" } const valueOptionSpecs: ReadonlyArray = [ @@ -79,12 +75,7 @@ const valueOptionSpecs: ReadonlyArray = [ { flag: "--out-dir", key: "outDir" }, { flag: "--project-dir", key: "projectDir" }, { flag: "--lines", key: "lines" }, - { flag: "--auto", key: "agentAutoMode" }, - { flag: "--pr-number", key: "prNumber" }, - { flag: "--pr", key: "prNumber" }, - { flag: "--repo", key: "repo" }, - { flag: "--limit", key: "limit" }, - { flag: "--output", key: "output" } + { flag: "--auto", key: "agentAutoMode" } ] const valueOptionSpecByFlag: ReadonlyMap = new Map( @@ -107,8 +98,7 @@ const booleanFlagUpdaters: Readonly RawOptio "--no-wipe": (raw) => ({ ...raw, wipe: false }), "--web": (raw) => ({ ...raw, authWeb: true }), "--include-default": (raw) => ({ ...raw, includeDefault: true }), - "--auto": (raw) => ({ ...raw, agentAutoMode: "auto" }), - "--no-comment": (raw) => ({ ...raw, noComment: true }) + "--auto": (raw) => ({ ...raw, agentAutoMode: "auto" }) } const valueFlagUpdaters: { readonly [K in ValueKey]: (raw: RawOptions, value: string) => RawOptions } = { @@ -142,11 +132,7 @@ const valueFlagUpdaters: { readonly [K in ValueKey]: (raw: RawOptions, value: st outDir: (raw, value) => ({ ...raw, outDir: value }), projectDir: (raw, value) => ({ ...raw, projectDir: value }), lines: (raw, value) => ({ ...raw, lines: value }), - agentAutoMode: (raw, value) => ({ ...raw, agentAutoMode: value.trim().toLowerCase() }), - prNumber: (raw, value) => ({ ...raw, prNumber: value }), - repo: (raw, value) => ({ ...raw, repo: value }), - limit: (raw, value) => ({ ...raw, limit: value }), - output: (raw, value) => ({ ...raw, output: value }) + agentAutoMode: (raw, value) => ({ ...raw, agentAutoMode: value.trim().toLowerCase() }) } export const applyCommandBooleanFlag = (raw: RawOptions, token: string): RawOptions | null => { diff --git a/packages/app/src/docker-git/cli/parser-session-gists.ts b/packages/app/src/docker-git/cli/parser-session-gists.ts deleted file mode 100644 index 67c018eb..00000000 --- a/packages/app/src/docker-git/cli/parser-session-gists.ts +++ /dev/null @@ -1,103 +0,0 @@ -import { Either, Match } from "effect" - -import { - type ParseError, - type SessionGistBackupCommand, - type SessionGistCommand, - type SessionGistDownloadCommand, - type SessionGistListCommand, - type SessionGistViewCommand -} from "../frontend-lib/core/domain.js" - -import { parsePositiveInt, parseProjectDirWithOptions, splitSubcommand } from "./parser-shared.js" - -// CHANGE: parse session backup commands for backup/list/view/download -// WHY: enables CLI access to session backup repository functionality -// QUOTE(ТЗ): "иметь возможность возвращаться ко всем старым сессиям с агентами" -// REF: issue-143 -// PURITY: CORE -// EFFECT: Either -// INVARIANT: all subcommands are deterministically parsed -// COMPLEXITY: O(n) where n = |args| - -const defaultLimit = 20 -const defaultOutputDir = "./.session-restore" - -const missingSnapshotRefError: ParseError = { _tag: "MissingRequiredOption", option: "snapshot-ref" } - -const extractSnapshotRef = (args: ReadonlyArray): string | null => { - const snapshotRef = args[0] - return snapshotRef && !snapshotRef.startsWith("-") ? snapshotRef : null -} - -const parseBackup = ( - args: ReadonlyArray -): Either.Either => - Either.map(parseProjectDirWithOptions(args), ({ projectDir, raw }) => ({ - _tag: "SessionGistBackup", - projectDir, - prNumber: raw.prNumber ? Number.parseInt(raw.prNumber, 10) : null, - repo: raw.repo ?? null, - postComment: raw.noComment !== true - })) - -const parseList = ( - args: ReadonlyArray -): Either.Either => - Either.gen(function*(_) { - const { raw } = yield* _(parseProjectDirWithOptions(args)) - const limit = raw.limit - ? yield* _(parsePositiveInt("--limit", raw.limit)) - : defaultLimit - return { - _tag: "SessionGistList", - limit, - repo: raw.repo ?? null - } - }) - -const parseView = ( - args: ReadonlyArray -): Either.Either => { - const snapshotRef = extractSnapshotRef(args) - return snapshotRef - ? Either.right({ _tag: "SessionGistView", snapshotRef }) - : Either.left(missingSnapshotRefError) -} - -const parseDownload = ( - args: ReadonlyArray -): Either.Either => { - const snapshotRef = extractSnapshotRef(args) - if (!snapshotRef) { - return Either.left(missingSnapshotRefError) - } - return Either.map(parseProjectDirWithOptions(args.slice(1)), ({ raw }) => ({ - _tag: "SessionGistDownload", - snapshotRef, - outputDir: raw.output ?? defaultOutputDir - })) -} - -const unknownActionError = (action: string): ParseError => ({ - _tag: "InvalidOption", - option: "session-gists", - reason: `unknown action ${action}` -}) - -export const parseSessionGists = ( - args: ReadonlyArray -): Either.Either => { - const { rest, subcommand } = splitSubcommand(args) - if (subcommand === null) { - return parseList(args) - } - - return Match.value(subcommand).pipe( - Match.when("backup", () => parseBackup(rest)), - Match.when("list", () => parseList(rest)), - Match.when("view", () => parseView(rest)), - Match.when("download", () => parseDownload(rest)), - Match.orElse(() => Either.left(unknownActionError(subcommand))) - ) -} diff --git a/packages/app/src/docker-git/cli/parser-shared.ts b/packages/app/src/docker-git/cli/parser-shared.ts index 086cb130..e48171fe 100644 --- a/packages/app/src/docker-git/cli/parser-shared.ts +++ b/packages/app/src/docker-git/cli/parser-shared.ts @@ -75,7 +75,7 @@ export const parsePositiveInt = ( } // CHANGE: shared helper to extract first arg and rest for subcommand parsing -// WHY: avoid code duplication in parser-sessions and parser-session-gists +// WHY: keep parser-sessions action dispatch deterministic // QUOTE(ТЗ): "иметь возможность возвращаться ко всем старым сессиям с агентами" // REF: issue-143 // PURITY: CORE diff --git a/packages/app/src/docker-git/cli/parser.ts b/packages/app/src/docker-git/cli/parser.ts index 290e287e..509b199f 100644 --- a/packages/app/src/docker-git/cli/parser.ts +++ b/packages/app/src/docker-git/cli/parser.ts @@ -12,7 +12,6 @@ import { parseOpen } from "./parser-open.js" import { parseRawOptions } from "./parser-options.js" import { parsePanes } from "./parser-panes.js" import { parseScrap } from "./parser-scrap.js" -import { parseSessionGists } from "./parser-session-gists.js" import { parseSessions } from "./parser-sessions.js" import { parseState } from "./parser-state.js" import { usageText } from "./usage.js" @@ -99,8 +98,6 @@ export const parseArgs = (args: ReadonlyArray): Either.Either parseOpen(rest)), Match.when("apply", () => parseApply(rest)), Match.when("state", () => parseState(rest)), - Match.when("session-gists", () => parseSessionGists(rest)), - Match.when("gists", () => parseSessionGists(rest)), Match.orElse(() => Either.left(unknownCommandError)) ) } diff --git a/packages/app/src/docker-git/cli/usage.ts b/packages/app/src/docker-git/cli/usage.ts index 65922152..c672cdda 100644 --- a/packages/app/src/docker-git/cli/usage.ts +++ b/packages/app/src/docker-git/cli/usage.ts @@ -13,10 +13,6 @@ docker-git scrap [] [options] docker-git sessions [list] [] [options] docker-git sessions kill [] [options] docker-git sessions logs [] [options] -docker-git session-gists [list] [options] -docker-git session-gists backup [] [options] -docker-git session-gists view -docker-git session-gists download [options] docker-git ps docker-git apply-all [--active] docker-git down-all @@ -35,7 +31,6 @@ Commands: panes, terms List tmux panes for a docker-git project scrap Export/import project scrap (session snapshot + rebuildable deps) sessions List/kill/log container terminal processes - session-gists Manage AI session backups via a private session repository (backup/list/view/download) ps, status Show docker compose status for all docker-git projects apply-all Apply docker-git config and refresh all containers (docker compose up); use --active to restrict to running containers only down-all Stop all docker-git containers (docker compose down) @@ -72,11 +67,6 @@ Options: --wipe | --no-wipe Wipe workspace before scrap import (default: --wipe) --lines Tail last N lines for sessions logs (default: 200) --include-default Show default/system processes in sessions list - --pr-number PR number for session backup comment - --repo Repository for session backup operations - --limit Limit for session backup snapshot list (default: 20) - --output Output directory for session backup download (default: ./.session-restore) - --no-comment Skip posting PR comment after session backup --up | --no-up Run docker compose up after init (default: --up) --ssh | --no-ssh Auto-open SSH after create/clone (default: clone=--ssh, create=--no-ssh) --mcp-playwright | --no-mcp-playwright Enable Playwright MCP + Chromium sidecar (default: --no-mcp-playwright) diff --git a/packages/app/src/docker-git/frontend-lib/core/command-options.ts b/packages/app/src/docker-git/frontend-lib/core/command-options.ts index 654be97e..908aee39 100644 --- a/packages/app/src/docker-git/frontend-lib/core/command-options.ts +++ b/packages/app/src/docker-git/frontend-lib/core/command-options.ts @@ -53,12 +53,6 @@ export interface RawOptions { readonly force?: boolean readonly forceEnv?: boolean readonly agentAutoMode?: string - // Session gist options (issue-143) - readonly prNumber?: string - readonly repo?: string - readonly noComment?: boolean - readonly limit?: string - readonly output?: string } // CHANGE: helper type alias for builder signatures that produce parse errors diff --git a/packages/app/src/docker-git/frontend-lib/core/domain.ts b/packages/app/src/docker-git/frontend-lib/core/domain.ts index 1c9a647e..d241d12f 100644 --- a/packages/app/src/docker-git/frontend-lib/core/domain.ts +++ b/packages/app/src/docker-git/frontend-lib/core/domain.ts @@ -200,14 +200,6 @@ export interface DownAllCommand { readonly _tag: "DownAll" } -export type { - SessionGistBackupCommand, - SessionGistCommand, - SessionGistDownloadCommand, - SessionGistListCommand, - SessionGistViewCommand -} from "./session-gist-domain.js" - export type ScrapCommand = | ScrapExportCommand | ScrapImportCommand diff --git a/packages/app/src/docker-git/frontend-lib/core/session-gist-domain.ts b/packages/app/src/docker-git/frontend-lib/core/session-gist-domain.ts deleted file mode 100644 index e3f29856..00000000 --- a/packages/app/src/docker-git/frontend-lib/core/session-gist-domain.ts +++ /dev/null @@ -1,38 +0,0 @@ -/* jscpd:ignore-start */ -// CHANGE: session backup commands for PR-based session history -// WHY: enables returning to old AI sessions via a private backup repository -// QUOTE(ТЗ): "иметь возможность возвращаться ко всем старым сессиям с агентами" -// REF: issue-143 -// PURITY: CORE - -export interface SessionGistBackupCommand { - readonly _tag: "SessionGistBackup" - readonly projectDir: string - readonly prNumber: number | null - readonly repo: string | null - readonly postComment: boolean -} - -export interface SessionGistListCommand { - readonly _tag: "SessionGistList" - readonly limit: number - readonly repo: string | null -} - -export interface SessionGistViewCommand { - readonly _tag: "SessionGistView" - readonly snapshotRef: string -} - -export interface SessionGistDownloadCommand { - readonly _tag: "SessionGistDownload" - readonly snapshotRef: string - readonly outputDir: string -} - -export type SessionGistCommand = - | SessionGistBackupCommand - | SessionGistListCommand - | SessionGistViewCommand - | SessionGistDownloadCommand -/* jscpd:ignore-end */ diff --git a/packages/app/src/docker-git/frontend-lib/core/sessions-domain.ts b/packages/app/src/docker-git/frontend-lib/core/sessions-domain.ts index 1abead3c..e8b00e1d 100644 --- a/packages/app/src/docker-git/frontend-lib/core/sessions-domain.ts +++ b/packages/app/src/docker-git/frontend-lib/core/sessions-domain.ts @@ -1,6 +1,4 @@ /* jscpd:ignore-start */ -import type { SessionGistCommand } from "./session-gist-domain.js" - export interface SessionsListCommand { readonly _tag: "SessionsList" readonly projectDir: string @@ -24,5 +22,4 @@ export type SessionsCommand = | SessionsListCommand | SessionsKillCommand | SessionsLogsCommand - | SessionGistCommand /* jscpd:ignore-end */ diff --git a/packages/app/src/docker-git/program-unsupported.ts b/packages/app/src/docker-git/program-unsupported.ts index 61f44c3f..c24356da 100644 --- a/packages/app/src/docker-git/program-unsupported.ts +++ b/packages/app/src/docker-git/program-unsupported.ts @@ -5,10 +5,6 @@ export type UnsupportedOperationalCommandTag = | "ScrapImport" | "McpPlaywrightUp" | "Apply" - | "SessionGistBackup" - | "SessionGistList" - | "SessionGistView" - | "SessionGistDownload" | "AuthClaudeLogin" | "AuthClaudeStatus" | "AuthClaudeLogout" @@ -32,22 +28,6 @@ export const unsupportedOperationalCommands: Record< command: "Apply", message: "Command Apply is not available in API-only host mode." }, - SessionGistBackup: { - command: "session-gists backup", - message: "Session gist backup is disabled in API-only host mode." - }, - SessionGistList: { - command: "session-gists list", - message: "Session gist list is disabled in API-only host mode." - }, - SessionGistView: { - command: "session-gists view", - message: "Session gist view is disabled in API-only host mode." - }, - SessionGistDownload: { - command: "session-gists download", - message: "Session gist download is disabled in API-only host mode." - }, AuthClaudeLogin: { command: "auth claude login", message: "Only GitHub auth is routed through the controller in host API mode." diff --git a/packages/app/src/lib/core/command-options.ts b/packages/app/src/lib/core/command-options.ts index 654be97e..908aee39 100644 --- a/packages/app/src/lib/core/command-options.ts +++ b/packages/app/src/lib/core/command-options.ts @@ -53,12 +53,6 @@ export interface RawOptions { readonly force?: boolean readonly forceEnv?: boolean readonly agentAutoMode?: string - // Session gist options (issue-143) - readonly prNumber?: string - readonly repo?: string - readonly noComment?: boolean - readonly limit?: string - readonly output?: string } // CHANGE: helper type alias for builder signatures that produce parse errors diff --git a/packages/app/src/lib/core/docker-git-scripts.ts b/packages/app/src/lib/core/docker-git-scripts.ts index b95a0f18..9e37f7ce 100644 --- a/packages/app/src/lib/core/docker-git-scripts.ts +++ b/packages/app/src/lib/core/docker-git-scripts.ts @@ -1,6 +1,6 @@ /* jscpd:ignore-start */ // CHANGE: define the set of docker-git scripts to embed in generated containers -// WHY: scripts (session-backup, pre-commit guards, knowledge splitter) must be available +// WHY: scripts (pre-commit guards, knowledge splitter) must be available // inside containers for git hooks and docker-git module usage // REF: issue-176 // SOURCE: n/a @@ -12,8 +12,7 @@ /** * Names of docker-git scripts that must be available inside generated containers. * - * These scripts are referenced by git hooks (pre-push, pre-commit), the global - * git push post-action runtime, and session backup workflows. They are copied into + * These scripts are referenced by git hooks (pre-push, pre-commit). They are copied into * each project's build context under * `scripts/` and embedded into the Docker image at `/opt/docker-git/scripts/`. * @@ -21,9 +20,6 @@ * @invariant ∀ name ∈ result: ∃ file(scripts/{name}) in docker-git workspace */ export const dockerGitScriptNames: ReadonlyArray = [ - "session-backup-gist.js", - "session-backup-repo.js", - "session-list-gists.js", "pre-commit-secret-guard.sh", "pre-push-knowledge-guard.js", "split-knowledge-large-files.js", diff --git a/packages/app/src/lib/core/domain.ts b/packages/app/src/lib/core/domain.ts index 60cb7674..420a2193 100644 --- a/packages/app/src/lib/core/domain.ts +++ b/packages/app/src/lib/core/domain.ts @@ -196,14 +196,6 @@ export interface DownAllCommand { readonly _tag: "DownAll" } -export type { - SessionGistBackupCommand, - SessionGistCommand, - SessionGistDownloadCommand, - SessionGistListCommand, - SessionGistViewCommand -} from "./session-gist-domain.js" - export type ScrapCommand = | ScrapExportCommand | ScrapImportCommand diff --git a/packages/app/src/lib/core/session-gist-domain.ts b/packages/app/src/lib/core/session-gist-domain.ts deleted file mode 100644 index e3f29856..00000000 --- a/packages/app/src/lib/core/session-gist-domain.ts +++ /dev/null @@ -1,38 +0,0 @@ -/* jscpd:ignore-start */ -// CHANGE: session backup commands for PR-based session history -// WHY: enables returning to old AI sessions via a private backup repository -// QUOTE(ТЗ): "иметь возможность возвращаться ко всем старым сессиям с агентами" -// REF: issue-143 -// PURITY: CORE - -export interface SessionGistBackupCommand { - readonly _tag: "SessionGistBackup" - readonly projectDir: string - readonly prNumber: number | null - readonly repo: string | null - readonly postComment: boolean -} - -export interface SessionGistListCommand { - readonly _tag: "SessionGistList" - readonly limit: number - readonly repo: string | null -} - -export interface SessionGistViewCommand { - readonly _tag: "SessionGistView" - readonly snapshotRef: string -} - -export interface SessionGistDownloadCommand { - readonly _tag: "SessionGistDownload" - readonly snapshotRef: string - readonly outputDir: string -} - -export type SessionGistCommand = - | SessionGistBackupCommand - | SessionGistListCommand - | SessionGistViewCommand - | SessionGistDownloadCommand -/* jscpd:ignore-end */ diff --git a/packages/app/src/lib/core/sessions-domain.ts b/packages/app/src/lib/core/sessions-domain.ts index 1abead3c..e8b00e1d 100644 --- a/packages/app/src/lib/core/sessions-domain.ts +++ b/packages/app/src/lib/core/sessions-domain.ts @@ -1,6 +1,4 @@ /* jscpd:ignore-start */ -import type { SessionGistCommand } from "./session-gist-domain.js" - export interface SessionsListCommand { readonly _tag: "SessionsList" readonly projectDir: string @@ -24,5 +22,4 @@ export type SessionsCommand = | SessionsListCommand | SessionsKillCommand | SessionsLogsCommand - | SessionGistCommand /* jscpd:ignore-end */ diff --git a/packages/app/src/lib/core/templates-entrypoint/git.ts b/packages/app/src/lib/core/templates-entrypoint/git.ts index 4bd9cf1a..9e8ffadd 100644 --- a/packages/app/src/lib/core/templates-entrypoint/git.ts +++ b/packages/app/src/lib/core/templates-entrypoint/git.ts @@ -280,16 +280,10 @@ cd "$REPO_ROOT" # REF: issue-192 if [ "${"${"}DOCKER_GIT_SKIP_SESSION_BACKUP:-}" != "1" ]; then if command -v gh >/dev/null 2>&1; then - BACKUP_SCRIPT="" - if [ -f "$REPO_ROOT/scripts/session-backup-gist.js" ]; then - BACKUP_SCRIPT="$REPO_ROOT/scripts/session-backup-gist.js" - elif [ -f /opt/docker-git/scripts/session-backup-gist.js ]; then - BACKUP_SCRIPT="/opt/docker-git/scripts/session-backup-gist.js" - fi - if [ -n "$BACKUP_SCRIPT" ]; then - DOCKER_GIT_SKIP_POST_PUSH_ACTION=1 node "$BACKUP_SCRIPT" || echo "[session-backup] Warning: session backup failed (non-fatal)" + if command -v docker-git-session-sync >/dev/null 2>&1; then + DOCKER_GIT_SKIP_POST_PUSH_ACTION=1 docker-git-session-sync backup --verbose || echo "[session-backup] Warning: session backup failed (non-fatal)" else - echo "[session-backup] Warning: script not found (expected repo or global path)" + echo "[session-backup] Warning: docker-git-session-sync not found (skipping session backup)" fi else echo "[session-backup] Warning: gh CLI not found (skipping session backup)" diff --git a/packages/app/src/lib/core/templates-entrypoint/tasks.ts b/packages/app/src/lib/core/templates-entrypoint/tasks.ts index fb1aa7e8..f596ed65 100644 --- a/packages/app/src/lib/core/templates-entrypoint/tasks.ts +++ b/packages/app/src/lib/core/templates-entrypoint/tasks.ts @@ -194,7 +194,7 @@ const renderCloneBody = (config: TemplateConfig): string => ].join("\n") // CHANGE: provision docker-git scripts into workspace after successful clone -// WHY: git hooks reference scripts/ relative to repo root (e.g. "bun scripts/session-backup-gist.js"); +// WHY: git hooks reference scripts/ relative to repo root; // symlinking embedded /opt/docker-git/scripts makes them available in any cloned repo // REF: issue-176 // PURITY: SHELL diff --git a/packages/app/src/lib/core/templates.ts b/packages/app/src/lib/core/templates.ts index d36a7469..4e83677a 100644 --- a/packages/app/src/lib/core/templates.ts +++ b/packages/app/src/lib/core/templates.ts @@ -14,8 +14,9 @@ const renderGitignore = (): string => `# docker-git project files # NOTE: bootstrap secrets stay local-only and should not be committed. -# docker-git scripts (copied from workspace, rebuilt on each project update) +# docker-git scripts/tools (copied from workspace, rebuilt on each project update) scripts/ +.docker-git-tools/ # Volatile Codex artifacts (do not commit) authorized_keys diff --git a/packages/app/src/lib/core/templates/dockerfile.ts b/packages/app/src/lib/core/templates/dockerfile.ts index 78f3a795..3325236e 100644 --- a/packages/app/src/lib/core/templates/dockerfile.ts +++ b/packages/app/src/lib/core/templates/dockerfile.ts @@ -264,17 +264,20 @@ RUN printf "%s\\n" \ "AllowUsers ${config.sshUser}" \ > /etc/ssh/sshd_config.d/${config.sshUser}.conf` -// CHANGE: add docker-git scripts to Docker image at /opt/docker-git/scripts -// WHY: scripts (session-backup, pre-commit guards, knowledge splitter) must be available -// inside the container for git hooks and docker-git module usage +// CHANGE: add docker-git scripts and session sync tool to Docker image +// WHY: git hooks need embedded scripts, while session sync is provided by a standalone tool // REF: issue-176 // PURITY: CORE (pure template renderer) -// INVARIANT: ∀ script ∈ scripts/: accessible(/opt/docker-git/scripts/{script}) +// INVARIANT: scripts are accessible under /opt/docker-git/scripts and session sync under PATH const renderDockerfileScripts = (): string => - `# docker-git scripts (hooks, session backup, knowledge guards) + `# docker-git scripts (hooks, knowledge guards) COPY scripts/ /opt/docker-git/scripts/ RUN find /opt/docker-git/scripts -type f -name '*.sh' -exec chmod +x {} + \ - && find /opt/docker-git/scripts -type f -name '*.js' -exec chmod +x {} +` + && find /opt/docker-git/scripts -type f -name '*.js' -exec chmod +x {} + + +# docker-git standalone tools +COPY .docker-git-tools/docker-git-session-sync /usr/local/bin/docker-git-session-sync +RUN chmod +x /usr/local/bin/docker-git-session-sync` const renderDockerfileWorkspace = (config: TemplateConfig): string => `# Workspace path (supports root-level dirs like /repo) diff --git a/packages/app/src/lib/shell/files.ts b/packages/app/src/lib/shell/files.ts index 1f5be499..68b90748 100644 --- a/packages/app/src/lib/shell/files.ts +++ b/packages/app/src/lib/shell/files.ts @@ -2,6 +2,8 @@ import type { PlatformError } from "@effect/platform/Error" import type * as FileSystem from "@effect/platform/FileSystem" import type * as Path from "@effect/platform/Path" +import { createRequire } from "node:module" +import nodePath from "node:path" import { Effect, Match } from "effect" import { dockerGitScriptNames } from "../core/docker-git-scripts.js" @@ -15,6 +17,9 @@ import { resolveWorkspaceRoot } from "./workspace-root.js" const ensureParentDir = (path: Path.Path, fs: FileSystem.FileSystem, filePath: string) => fs.makeDirectory(path.dirname(filePath), { recursive: true }) +const require = createRequire(import.meta.url) +const sessionSyncToolRelativePath = ".docker-git-tools/docker-git-session-sync" + const fallbackHostResources = { cpuCount: 1, totalMemoryBytes: 1024 ** 3 @@ -140,6 +145,54 @@ const provisionDockerGitScripts = ( } }) +const resolveInstalledSessionSyncTool = (): string | null => { + try { + const packageJsonPath = require.resolve("@prover-coder-ai/docker-git-session-sync/package.json") + return nodePath.join(nodePath.dirname(packageJsonPath), "dist", "docker-git-session-sync.js") + } catch { + return null + } +} + +const sessionSyncToolCandidates = (path: Path.Path, workspaceRoot: string): ReadonlyArray => { + const installed = resolveInstalledSessionSyncTool() + const workspaceCandidate = path.join( + workspaceRoot, + "packages", + "docker-git-session-sync", + "dist", + "docker-git-session-sync.js" + ) + return installed === null ? [workspaceCandidate] : [workspaceCandidate, installed] +} + +// CHANGE: provision standalone session sync tool into the Docker build context +// WHY: generated containers call docker-git-session-sync directly after git push +// REF: issue-230 +// PURITY: SHELL +// EFFECT: Effect +// INVARIANT: target executable exists before Dockerfile COPY is evaluated +// COMPLEXITY: O(k) where k = candidate tool locations +const provisionDockerGitSessionSyncTool = ( + fs: FileSystem.FileSystem, + path: Path.Path, + baseDir: string +): Effect.Effect => + Effect.gen(function*(_) { + const workspaceRoot = yield* _(resolveWorkspaceRoot(process.cwd())) + const targetPath = path.join(baseDir, sessionSyncToolRelativePath) + for (const sourcePath of sessionSyncToolCandidates(path, workspaceRoot)) { + const exists = yield* _(fs.exists(sourcePath)) + if (exists) { + const contents = yield* _(fs.readFileString(sourcePath)) + yield* _(ensureParentDir(path, fs, targetPath)) + yield* _(fs.writeFileString(targetPath, contents, { mode: 0o755 })) + return + } + } + yield* _(Effect.dieMessage("docker-git-session-sync build artifact not found; run bun run --cwd packages/docker-git-session-sync build")) + }) + // CHANGE: write generated docker-git files to disk // WHY: isolate all filesystem effects in a thin shell // QUOTE(ТЗ): "создавать докер образы" @@ -192,6 +245,7 @@ export const writeProjectFiles = ( // WHY: Dockerfile COPY scripts/ requires scripts to be in the build context // REF: issue-176 yield* _(provisionDockerGitScripts(fs, path, baseDir)) + yield* _(provisionDockerGitSessionSyncTool(fs, path, baseDir)) return created }) diff --git a/packages/app/src/lib/usecases/session-gists.ts b/packages/app/src/lib/usecases/session-gists.ts deleted file mode 100644 index 1f17dbc6..00000000 --- a/packages/app/src/lib/usecases/session-gists.ts +++ /dev/null @@ -1,94 +0,0 @@ -/* jscpd:ignore-start */ -import type * as CommandExecutor from "@effect/platform/CommandExecutor" -import type { PlatformError } from "@effect/platform/Error" -import { Effect } from "effect" - -import type { - SessionGistBackupCommand, - SessionGistDownloadCommand, - SessionGistListCommand, - SessionGistViewCommand -} from "../core/domain.js" -import { runCommandWithExitCodes } from "../shell/command-runner.js" -import { CommandFailedError } from "../shell/errors.js" - -// CHANGE: implement session backup repository operations via shell commands -// WHY: enables CLI access to session backup/list/view/download functionality -// QUOTE(ТЗ): "иметь возможность возвращаться ко всем старым сессиям с агентами" -// REF: issue-143 -// PURITY: SHELL -// EFFECT: Effect -// INVARIANT: all operations require gh CLI authentication -// COMPLEXITY: O(n) where n = number of files/gists - -type SessionGistsError = CommandFailedError | PlatformError -type SessionGistsRequirements = CommandExecutor.CommandExecutor - -const nodeOk = [0] - -const makeNodeSpec = (scriptPath: string, args: ReadonlyArray) => ({ - cwd: process.cwd(), - command: "node", - args: [scriptPath, ...args] -}) - -const runNodeScript = ( - scriptPath: string, - args: ReadonlyArray -): Effect.Effect => - runCommandWithExitCodes( - makeNodeSpec(scriptPath, args), - nodeOk, - (exitCode) => new CommandFailedError({ command: `node ${scriptPath}`, exitCode }) - ) - -export const sessionGistBackup = ( - cmd: SessionGistBackupCommand -): Effect.Effect => { - const args: Array = ["--verbose"] - if (cmd.prNumber !== null) { - args.push("--pr-number", cmd.prNumber.toString()) - } - if (cmd.repo !== null) { - args.push("--repo", cmd.repo) - } - if (!cmd.postComment) { - args.push("--no-comment") - } - return Effect.gen(function*(_) { - yield* _(Effect.log("Backing up AI session to private session repository...")) - yield* _(runNodeScript("scripts/session-backup-gist.js", args)) - yield* _(Effect.log("Session backup complete.")) - }) -} - -export const sessionGistList = ( - cmd: SessionGistListCommand -): Effect.Effect => { - const args: Array = ["list", "--limit", cmd.limit.toString()] - if (cmd.repo !== null) { - args.push("--repo", cmd.repo) - } - return Effect.gen(function*(_) { - yield* _(Effect.log("Listing session backup snapshots...")) - yield* _(runNodeScript("scripts/session-list-gists.js", args)) - }) -} - -export const sessionGistView = ( - cmd: SessionGistViewCommand -): Effect.Effect => - Effect.gen(function*(_) { - yield* _(Effect.log(`Viewing snapshot: ${cmd.snapshotRef}`)) - yield* _(runNodeScript("scripts/session-list-gists.js", ["view", cmd.snapshotRef])) - }) - -export const sessionGistDownload = ( - cmd: SessionGistDownloadCommand -): Effect.Effect => - Effect.gen(function*(_) { - yield* _(Effect.log(`Downloading snapshot ${cmd.snapshotRef} to ${cmd.outputDir}...`)) - yield* _(runNodeScript("scripts/session-list-gists.js", ["download", cmd.snapshotRef, "--output", cmd.outputDir])) - yield* _(Effect.log("Download complete.")) - }) -/* jscpd:ignore-end */ diff --git a/packages/app/tests/docker-git/parser-session-sync-removal.test.ts b/packages/app/tests/docker-git/parser-session-sync-removal.test.ts new file mode 100644 index 00000000..5fe687a7 --- /dev/null +++ b/packages/app/tests/docker-git/parser-session-sync-removal.test.ts @@ -0,0 +1,12 @@ +import { describe, it } from "@effect/vitest" +import { Effect } from "effect" + +import { expectParseErrorTag } from "./parser-helpers.js" + +describe("parseArgs session sync removal", () => { + it.effect("rejects removed session-gists commands", () => + Effect.gen(function*(_) { + yield* _(expectParseErrorTag(["session-gists"], "UnknownCommand")) + yield* _(expectParseErrorTag(["gists"], "UnknownCommand")) + })) +}) diff --git a/packages/app/tests/hooks/session-backup-gist.test.ts b/packages/app/tests/hooks/session-backup-gist.test.ts deleted file mode 100644 index dce5aa25..00000000 --- a/packages/app/tests/hooks/session-backup-gist.test.ts +++ /dev/null @@ -1,74 +0,0 @@ -// CHANGE: add regression coverage for session backup tmp filtering -// WHY: session snapshots must ignore transient tmp directories while preserving persistent files -// REF: issue-198 -// PURITY: SHELL (tests filesystem traversal against committed backup script) - -import { describe, expect, it } from "@effect/vitest" -import { Effect } from "effect" -import fs from "node:fs" -import path from "node:path" - -import sessionBackupGist from "../../../../scripts/session-backup-gist.js" - -const { collectSessionFiles, shouldIgnoreSessionPath } = sessionBackupGist -const tmpDirPrefix = path.join(process.cwd(), ".tmp-session-backup-gist-") - -const withTempDir = Effect.acquireRelease( - Effect.sync(() => fs.mkdtempSync(tmpDirPrefix)), - (tmpDir) => - Effect.sync(() => { - fs.rmSync(tmpDir, { recursive: true, force: true }) - }) -) - -describe("session-backup-gist tmp filtering", () => { - it.effect("ignores tmp directories while keeping persistent session files", () => - Effect.scoped( - Effect.gen(function*(_) { - const tmpDir = yield* _(withTempDir) - const codexDir = path.join(tmpDir, ".codex") - const claudeDir = path.join(tmpDir, ".claude") - - yield* _( - Effect.sync(() => { - fs.mkdirSync(path.join(codexDir, "tmp", "run"), { recursive: true }) - fs.mkdirSync(path.join(codexDir, "memory"), { recursive: true }) - fs.mkdirSync(path.join(claudeDir, "tmp"), { recursive: true }) - fs.mkdirSync(path.join(claudeDir, "profiles"), { recursive: true }) - - fs.writeFileSync(path.join(codexDir, "tmp", "run", ".lock"), "lock") - fs.writeFileSync(path.join(codexDir, "history.jsonl"), "{\"event\":1}\n") - fs.writeFileSync(path.join(codexDir, "memory", "notes.md"), "# notes\n") - fs.writeFileSync(path.join(claudeDir, "tmp", "session.lock"), "lock") - fs.writeFileSync(path.join(claudeDir, "profiles", "default.json"), "{}\n") - }) - ) - - const files = [ - ...collectSessionFiles(codexDir, ".codex", false), - ...collectSessionFiles(claudeDir, ".claude", false) - ] - const logicalNames = files - .map((file) => file.logicalName) - .toSorted((left, right) => left.localeCompare(right)) - - yield* _( - Effect.sync(() => { - expect(logicalNames).toContain(".codex/history.jsonl") - expect(logicalNames).toContain(".codex/memory/notes.md") - expect(logicalNames).toContain(".claude/profiles/default.json") - expect(logicalNames.some((name) => name.split("/").includes("tmp"))).toBe(false) - }) - ) - }) - )) - - it.effect("marks tmp paths for exclusion", () => - Effect.sync(() => { - expect(shouldIgnoreSessionPath("tmp")).toBe(true) - expect(shouldIgnoreSessionPath("tmp/run/.lock")).toBe(true) - expect(shouldIgnoreSessionPath("memory/tmp/run/.lock")).toBe(true) - expect(shouldIgnoreSessionPath("history.jsonl")).toBe(false) - expect(shouldIgnoreSessionPath("memory/notes.md")).toBe(false) - })) -}) diff --git a/packages/docker-git-session-sync/package.json b/packages/docker-git-session-sync/package.json new file mode 100644 index 00000000..ba900723 --- /dev/null +++ b/packages/docker-git-session-sync/package.json @@ -0,0 +1,47 @@ +{ + "name": "@prover-coder-ai/docker-git-session-sync", + "version": "1.0.0", + "description": "Standalone docker-git AI agent session synchronization tool", + "main": "dist/docker-git-session-sync.js", + "bin": { + "docker-git-session-sync": "dist/docker-git-session-sync.js" + }, + "files": [ + "dist" + ], + "scripts": { + "build": "vite build && chmod +x dist/docker-git-session-sync.js", + "check": "bun run typecheck", + "test": "vitest run --passWithNoTests", + "typecheck": "tsc --noEmit -p tsconfig.json" + }, + "repository": { + "type": "git", + "url": "git+https://github.com/ProverCoderAI/docker-git.git" + }, + "keywords": [ + "docker-git", + "session", + "agents" + ], + "author": "", + "license": "ISC", + "type": "module", + "bugs": { + "url": "https://github.com/ProverCoderAI/docker-git/issues" + }, + "homepage": "https://github.com/ProverCoderAI/docker-git#readme", + "packageManager": "bun@1.3.11", + "dependencies": { + "@effect/platform-node": "^0.106.0", + "effect": "^3.21.0" + }, + "devDependencies": { + "@effect/vitest": "^0.29.0", + "@types/node": "^24.12.0", + "@vitejs/plugin-react": "^6.0.1", + "typescript": "^5.9.3", + "vite": "^8.0.1", + "vitest": "^4.1.0" + } +} diff --git a/packages/docker-git-session-sync/src/backup.ts b/packages/docker-git-session-sync/src/backup.ts new file mode 100644 index 00000000..62e8a0c7 --- /dev/null +++ b/packages/docker-git-session-sync/src/backup.ts @@ -0,0 +1,460 @@ +import fs from "node:fs" +import os from "node:os" +import path from "node:path" +import { spawnSync } from "node:child_process" + +import { + buildBlobUrl, + buildCommentBody, + buildManifest, + buildSnapshotReadme, + buildSnapshotRef, + formatBytes, + isPathWithinParent, + sessionDirNames, + sessionWalkIgnoreDirNames, + shouldIgnoreSessionPath, + sortSessionFiles, + summarizeFiles, + toLogicalRelativePath +} from "./core.js" +import { ensureBackupRepo, prepareUploadArtifacts, resolveGhEnvironment, runGitCapture, uploadSnapshot } from "./shell.js" +import type { GhEnv, Log, SessionFile } from "./types.js" + +export interface BackupOptions { + readonly sessionDir: string | null + readonly prNumber: number | null + readonly repo: string | null + readonly postComment: boolean + readonly dryRun: boolean + readonly verbose: boolean +} + +export interface Output { + readonly out: Log + readonly err: Log +} + +const logVerbose = (verbose: boolean, output: Output, message: string): void => { + if (verbose) { + output.out(`[session-backup] ${message}`) + } +} + +const getGitStatus = (cwd: string): string | null => { + const status = runGitCapture(cwd, ["status"]) + if (status === null) { + return null + } + return status.length === 0 ? "clean" : status +} + +const printGitStatus = (output: Output, status: string | null): void => { + output.out("[session-backup] git status:") + if (status === null) { + output.out("[session-backup] (unavailable)") + return + } + for (const line of status.split("\n")) { + output.out(`[session-backup] ${line}`) + } +} + +const parseGitHubRepoFromRemoteUrl = (remoteUrl: string): string | null => { + const sshMatch = remoteUrl.match(/git@github\.com:([^/]+\/[^.]+)(?:\.git)?$/u) + if (sshMatch?.[1] !== undefined) { + return sshMatch[1] + } + const httpsMatch = remoteUrl.match(/https:\/\/github\.com\/([^/]+\/[^.]+)(?:\.git)?$/u) + if (httpsMatch?.[1] !== undefined) { + return httpsMatch[1] + } + return null +} + +const rankRemoteName = (remoteName: string): number => { + if (remoteName === "upstream") { + return 0 + } + if (remoteName === "origin") { + return 1 + } + return 2 +} + +const getRepoCandidates = (cwd: string, explicitRepo: string | null, verbose: boolean, output: Output): ReadonlyArray => { + if (explicitRepo !== null) { + return [explicitRepo] + } + const remoteOutput = runGitCapture(cwd, ["remote", "-v"]) + if (remoteOutput === null) { + return [] + } + const remotes: Array<{ readonly remoteName: string; readonly repo: string }> = [] + const seenRepos = new Set() + for (const line of remoteOutput.split("\n")) { + const match = line.match(/^(\S+)\s+(\S+)\s+\((fetch|push)\)$/u) + if (match?.[1] === undefined || match[2] === undefined || match[3] !== "fetch") { + continue + } + const repo = parseGitHubRepoFromRemoteUrl(match[2]) + if (repo === null || seenRepos.has(repo)) { + continue + } + remotes.push({ remoteName: match[1], repo }) + seenRepos.add(repo) + } + remotes.sort((left, right) => { + const rankDiff = rankRemoteName(left.remoteName) - rankRemoteName(right.remoteName) + return rankDiff !== 0 ? rankDiff : left.remoteName.localeCompare(right.remoteName) + }) + const repos = remotes.map(({ repo }) => repo) + if (repos.length > 0) { + logVerbose(verbose, output, `Repository candidates: ${repos.join(", ")}`) + } + return repos +} + +const ghPrCommand = (args: ReadonlyArray, ghEnv: GhEnv): { readonly success: boolean; readonly stdout: string } => { + const result = spawnSync("gh", args, { + encoding: "utf8", + stdio: ["pipe", "pipe", "pipe"], + env: ghEnv + }) + return { + success: result.status === 0, + stdout: (result.stdout ?? "").trim() + } +} + +const getPrNumberFromBranch = (repo: string, branch: string, ghEnv: GhEnv): number | null => { + const result = ghPrCommand([ + "pr", + "list", + "--repo", + repo, + "--head", + branch, + "--json", + "number", + "--jq", + ".[0].number" + ], ghEnv) + const parsed = Number.parseInt(result.stdout, 10) + return result.success && !Number.isNaN(parsed) ? parsed : null +} + +const getPrState = (repo: string, prNumber: number, ghEnv: GhEnv): string | null => { + const result = ghPrCommand([ + "pr", + "view", + prNumber.toString(), + "--repo", + repo, + "--json", + "state", + "--jq", + ".state" + ], ghEnv) + return result.success ? result.stdout : null +} + +const prIsOpen = (repo: string, prNumber: number, ghEnv: GhEnv): boolean => + getPrState(repo, prNumber, ghEnv) === "OPEN" + +const getPrNumberFromWorkspaceBranch = (branch: string): number | null => { + const match = branch.match(/^pr-refs-pull-([0-9]+)-head$/u) + if (match?.[1] === undefined) { + return null + } + const prNumber = Number.parseInt(match[1], 10) + return Number.isNaN(prNumber) ? null : prNumber +} + +const findPrContext = ( + repos: ReadonlyArray, + branch: string, + verbose: boolean, + output: Output, + ghEnv: GhEnv +): { readonly repo: string; readonly prNumber: number } | null => { + for (const repo of repos) { + logVerbose(verbose, output, `Checking open PR in ${repo} for branch ${branch}`) + const prNumber = getPrNumberFromBranch(repo, branch, ghEnv) + if (prNumber !== null && prIsOpen(repo, prNumber, ghEnv)) { + return { repo, prNumber } + } + if (prNumber !== null) { + logVerbose(verbose, output, `Skipping PR #${prNumber} in ${repo}: PR is not open`) + } + } + + const workspacePrNumber = getPrNumberFromWorkspaceBranch(branch) + if (workspacePrNumber === null) { + return null + } + for (const repo of repos) { + logVerbose(verbose, output, `Checking workspace PR #${workspacePrNumber} in ${repo} for branch ${branch}`) + if (prIsOpen(repo, workspacePrNumber, ghEnv)) { + return { repo, prNumber: workspacePrNumber } + } + } + return null +} + +const getAllowedSessionRoots = (): ReadonlyArray<{ readonly name: string; readonly path: string }> => { + const homeDir = os.homedir() + return sessionDirNames + .map((dirName) => ({ name: dirName, path: path.join(homeDir, dirName) })) + .filter((entry) => fs.existsSync(entry.path)) +} + +const resolveAllowedSessionDir = ( + candidatePath: string, + verbose: boolean, + output: Output +): string | null => { + const resolvedPath = path.resolve(candidatePath) + if (!fs.existsSync(resolvedPath)) { + return null + } + for (const root of getAllowedSessionRoots()) { + if (isPathWithinParent(resolvedPath, root.path)) { + return resolvedPath + } + } + logVerbose(verbose, output, `Skipping non-session directory: ${candidatePath}`) + return null +} + +const findSessionDirs = ( + explicitPath: string | null, + verbose: boolean, + output: Output +): ReadonlyArray<{ readonly name: string; readonly path: string }> => { + if (explicitPath !== null) { + const allowedPath = resolveAllowedSessionDir(path.resolve(explicitPath), verbose, output) + if (allowedPath === null) { + throw new Error( + `--session-dir must point to a directory under ${sessionDirNames.map((dirName) => `~/${dirName}`).join(", ")}` + ) + } + return [{ name: path.basename(allowedPath), path: allowedPath }] + } + + const dirs: Array<{ readonly name: string; readonly path: string }> = [] + for (const root of getAllowedSessionRoots()) { + const allowedPath = resolveAllowedSessionDir(root.path, verbose, output) + if (allowedPath !== null) { + logVerbose(verbose, output, `Found session directory: ${allowedPath}`) + dirs.push({ name: root.name, path: allowedPath }) + } + } + return dirs +} + +export const collectSessionFiles = (dirPath: string, baseName: string, verbose: boolean, output: Output): ReadonlyArray => { + const files: Array = [] + const walk = (currentPath: string, relativePath: string): void => { + const entries = fs.readdirSync(currentPath, { withFileTypes: true }) + for (const entry of entries) { + const fullPath = path.join(currentPath, entry.name) + const relPath = relativePath.length > 0 ? `${relativePath}/${entry.name}` : entry.name + const logicalRelPath = toLogicalRelativePath(relPath) + if (shouldIgnoreSessionPath(logicalRelPath)) { + logVerbose(verbose, output, `Skipping tmp path: ${path.posix.join(baseName, logicalRelPath)}`) + continue + } + if (entry.isDirectory()) { + if (!sessionWalkIgnoreDirNames.has(entry.name)) { + walk(fullPath, relPath) + } + continue + } + if (!entry.isFile()) { + continue + } + try { + const stats = fs.statSync(fullPath) + const logicalName = path.posix.join(baseName, logicalRelPath) + files.push({ logicalName, sourcePath: fullPath, size: stats.size }) + logVerbose(verbose, output, `Collected file: ${logicalName} (${stats.size} bytes)`) + } catch (error) { + logVerbose(verbose, output, `Error reading file ${fullPath}: ${String(error)}`) + } + } + } + walk(dirPath, "") + return sortSessionFiles(files) +} + +const postPrComment = ( + repo: string, + prNumber: number, + comment: string, + verbose: boolean, + output: Output, + ghEnv: GhEnv +): boolean => { + logVerbose(verbose, output, `Posting comment to PR #${prNumber}`) + const result = ghPrCommand(["pr", "comment", prNumber.toString(), "--repo", repo, "--body", comment], ghEnv) + if (!result.success) { + output.err("[session-backup] Failed to post PR comment") + return false + } + logVerbose(verbose, output, "Comment posted successfully") + return true +} + +export const backupSessions = (options: BackupOptions, cwd: string, output: Output): number => { + if (process.env["DOCKER_GIT_SKIP_SESSION_BACKUP"] === "1") { + output.out("[session-backup] Skipped (DOCKER_GIT_SKIP_SESSION_BACKUP=1)") + return 0 + } + + const verbose = options.verbose + const ghEnv = resolveGhEnvironment(cwd, (message) => logVerbose(verbose, output, message)) + logVerbose(verbose, output, "Starting session backup...") + + const repoCandidates = getRepoCandidates(cwd, options.repo, verbose, output) + if (repoCandidates.length === 0) { + output.err("[session-backup] Could not determine source repository. Use --repo option.") + return 1 + } + const sourceRepo = repoCandidates[0] + if (sourceRepo === undefined) { + return 1 + } + logVerbose(verbose, output, `Repository: ${sourceRepo}`) + + const branch = runGitCapture(cwd, ["rev-parse", "--abbrev-ref", "HEAD"]) + if (branch === null || branch.length === 0) { + output.err("[session-backup] Could not determine current branch.") + return 1 + } + logVerbose(verbose, output, `Branch: ${branch}`) + + const commitSha = runGitCapture(cwd, ["rev-parse", "HEAD"]) + if (commitSha === null || commitSha.length === 0) { + output.err("[session-backup] Could not determine current commit.") + return 1 + } + + let prContext: { readonly repo: string; readonly prNumber: number } | null = null + if (options.prNumber !== null) { + if (prIsOpen(sourceRepo, options.prNumber, ghEnv)) { + prContext = { repo: sourceRepo, prNumber: options.prNumber } + } else { + logVerbose(verbose, output, `Skipping PR comment: PR #${options.prNumber} is not open`) + } + } else if (options.postComment) { + prContext = findPrContext(repoCandidates, branch, verbose, output, ghEnv) + } + + if (prContext !== null) { + logVerbose(verbose, output, `PR number: ${prContext.prNumber} (${prContext.repo})`) + } else if (options.postComment) { + logVerbose(verbose, output, "No PR found for current branch, skipping comment") + } + + const sessionDirs = findSessionDirs(options.sessionDir, verbose, output) + if (sessionDirs.length === 0) { + logVerbose(verbose, output, "No session directories found") + return 0 + } + + const sessionFiles = sessionDirs.flatMap((dir) => collectSessionFiles(dir.path, dir.name, verbose, output)) + if (sessionFiles.length === 0) { + logVerbose(verbose, output, "No session files found to backup") + return 0 + } + logVerbose(verbose, output, `Total files to backup: ${sessionFiles.length}`) + + const backupRepo = ensureBackupRepo(ghEnv, (message) => logVerbose(verbose, output, message), !options.dryRun) + if (backupRepo === null) { + output.err("[session-backup] Failed to resolve or create the private session backup repository") + return 1 + } + + const tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), "session-sync-repo-")) + try { + const snapshotCreatedAt = new Date().toISOString() + const snapshotRef = buildSnapshotRef(sourceRepo, prContext?.prNumber ?? null, commitSha, snapshotCreatedAt) + const prepared = prepareUploadArtifacts( + sessionFiles, + snapshotRef, + backupRepo.fullName, + backupRepo.defaultBranch, + tmpDir, + (message) => logVerbose(verbose, output, message) + ) + const source = { + repo: sourceRepo, + branch, + prNumber: prContext?.prNumber ?? null, + commitSha, + createdAt: snapshotCreatedAt + } + const summary = summarizeFiles(prepared.manifestFiles) + const sessionRoots = sessionDirs.map((dir) => `~/${dir.name}`) + const manifestUrl = buildBlobUrl(backupRepo.fullName, backupRepo.defaultBranch, `${snapshotRef}/manifest.json`) + const readmeRepoPath = `${snapshotRef}/README.md` + const readmeUrl = buildBlobUrl(backupRepo.fullName, backupRepo.defaultBranch, readmeRepoPath) + const gitStatus = getGitStatus(cwd) + const manifest = buildManifest({ + backupRepo, + snapshotRef, + source, + files: prepared.manifestFiles, + createdAt: snapshotCreatedAt + }) + const readmePath = path.join(tmpDir, "README.md") + fs.writeFileSync( + readmePath, + buildSnapshotReadme({ backupRepo, source, manifestUrl, summary, sessionRoots }), + "utf8" + ) + const uploadEntries = [ + ...prepared.uploadEntries, + { + repoPath: readmeRepoPath, + sourcePath: readmePath, + type: "readme", + size: fs.statSync(readmePath).size + } + ] + if (options.dryRun) { + output.out(`[session-backup] dry-run: ${source.commitSha.slice(0, 12)} (${summary.fileCount} files, ${formatBytes(summary.totalBytes)})`) + printGitStatus(output, gitStatus) + logVerbose(verbose, output, `[dry-run] Upload target: ${backupRepo.fullName}:${snapshotRef}`) + logVerbose(verbose, output, `[dry-run] README URL: ${readmeUrl}`) + logVerbose(verbose, output, `[dry-run] Manifest URL: ${manifestUrl}`) + if (options.postComment && prContext !== null) { + logVerbose(verbose, output, `Would post comment to PR #${prContext.prNumber} in ${prContext.repo}:`) + logVerbose(verbose, output, buildCommentBody({ source, manifestUrl, readmeUrl, summary, gitStatus })) + } + return 0 + } + + logVerbose(verbose, output, `Uploading snapshot to ${backupRepo.fullName}:${snapshotRef}`) + const uploadResult = uploadSnapshot(backupRepo, snapshotRef, manifest, uploadEntries, ghEnv) + output.out(`[session-backup] ok: ${source.commitSha.slice(0, 12)} (${summary.fileCount} files, ${formatBytes(summary.totalBytes)})`) + printGitStatus(output, gitStatus) + logVerbose(verbose, output, `[session-backup] Uploaded snapshot to ${backupRepo.fullName}:${snapshotRef}`) + logVerbose(verbose, output, `[session-backup] Manifest: ${uploadResult.manifestUrl}`) + + if (options.postComment && prContext !== null) { + postPrComment( + prContext.repo, + prContext.prNumber, + buildCommentBody({ source, manifestUrl: uploadResult.manifestUrl, readmeUrl, summary, gitStatus }), + verbose, + output, + ghEnv + ) + } + return 0 + } finally { + fs.rmSync(tmpDir, { recursive: true, force: true }) + } +} diff --git a/packages/docker-git-session-sync/src/cli.ts b/packages/docker-git-session-sync/src/cli.ts new file mode 100644 index 00000000..3f40ba01 --- /dev/null +++ b/packages/docker-git-session-sync/src/cli.ts @@ -0,0 +1,263 @@ +import { backupSessions, type BackupOptions, type Output } from "./backup.js" +import { downloadSnapshot, listSnapshots, viewSnapshot } from "./snapshots.js" + +const defaultLimit = 20 +const defaultOutputDir = "./.session-restore" + +const usageText = `Usage: + docker-git-session-sync backup [options] + docker-git-session-sync list [options] + docker-git-session-sync view [options] + docker-git-session-sync download [options] + +Options: + --session-dir Path under ~/.codex, ~/.claude, ~/.qwen, or ~/.gemini + --pr-number Open PR number to post comment to + --repo Source repository or list filter + --limit Maximum snapshots to list (default: 20) + --output Download directory (default: ./.session-restore) + --no-comment Skip posting a PR comment after backup + --dry-run Show what backup would upload + --verbose Enable verbose logging + --help Show help` + +type ParsedCommand = + | { readonly _tag: "Help" } + | ({ readonly _tag: "Backup" } & BackupOptions) + | { readonly _tag: "List"; readonly limit: number; readonly repo: string | null; readonly verbose: boolean } + | { readonly _tag: "View"; readonly snapshotRef: string; readonly verbose: boolean } + | { readonly _tag: "Download"; readonly snapshotRef: string; readonly outputDir: string; readonly verbose: boolean } + +type ParseResult = + | { readonly _tag: "Ok"; readonly command: ParsedCommand } + | { readonly _tag: "Error"; readonly message: string } + +const nextValue = (args: ReadonlyArray, index: number, option: string): string | ParseResult => { + const value = args[index + 1] + if (value === undefined || value.startsWith("--")) { + return { _tag: "Error", message: `${option} requires a value` } + } + return value +} + +const parsePositiveInt = (option: string, value: string): number | ParseResult => { + const parsed = Number.parseInt(value, 10) + return Number.isInteger(parsed) && parsed > 0 + ? parsed + : { _tag: "Error", message: `${option} must be a positive integer` } +} + +const parseBackup = (args: ReadonlyArray): ParseResult => { + const options: { + sessionDir: string | null + prNumber: number | null + repo: string | null + postComment: boolean + dryRun: boolean + verbose: boolean + } = { + sessionDir: null, + prNumber: null, + repo: null, + postComment: true, + dryRun: false, + verbose: false + } + let index = 0 + while (index < args.length) { + const arg = args[index] + if (arg === "--session-dir") { + const value = nextValue(args, index, arg) + if (typeof value !== "string") { + return value + } + options.sessionDir = value + index += 2 + continue + } + if (arg === "--pr-number") { + const value = nextValue(args, index, arg) + if (typeof value !== "string") { + return value + } + const parsed = parsePositiveInt(arg, value) + if (typeof parsed !== "number") { + return parsed + } + options.prNumber = parsed + index += 2 + continue + } + if (arg === "--repo") { + const value = nextValue(args, index, arg) + if (typeof value !== "string") { + return value + } + options.repo = value + index += 2 + continue + } + if (arg === "--no-comment") { + options.postComment = false + index += 1 + continue + } + if (arg === "--dry-run") { + options.dryRun = true + index += 1 + continue + } + if (arg === "--verbose") { + options.verbose = true + index += 1 + continue + } + return { _tag: "Error", message: `unknown backup option ${arg ?? ""}` } + } + return { _tag: "Ok", command: { _tag: "Backup", ...options } } +} + +const parseList = (args: ReadonlyArray): ParseResult => { + let limit = defaultLimit + let repo: string | null = null + let verbose = false + let index = 0 + while (index < args.length) { + const arg = args[index] + if (arg === "--limit") { + const value = nextValue(args, index, arg) + if (typeof value !== "string") { + return value + } + const parsed = parsePositiveInt(arg, value) + if (typeof parsed !== "number") { + return parsed + } + limit = parsed + index += 2 + continue + } + if (arg === "--repo") { + const value = nextValue(args, index, arg) + if (typeof value !== "string") { + return value + } + repo = value + index += 2 + continue + } + if (arg === "--verbose") { + verbose = true + index += 1 + continue + } + return { _tag: "Error", message: `unknown list option ${arg ?? ""}` } + } + return { _tag: "Ok", command: { _tag: "List", limit, repo, verbose } } +} + +const extractSnapshotRef = (args: ReadonlyArray): string | null => { + const first = args[0] + return first !== undefined && !first.startsWith("--") ? first : null +} + +const parseView = (args: ReadonlyArray): ParseResult => { + const snapshotRef = extractSnapshotRef(args) + if (snapshotRef === null) { + return { _tag: "Error", message: "view requires " } + } + const rest = args.slice(1) + const verbose = rest.includes("--verbose") + const unknown = rest.find((arg) => arg !== "--verbose") + if (unknown !== undefined) { + return { _tag: "Error", message: `unknown view option ${unknown}` } + } + return { _tag: "Ok", command: { _tag: "View", snapshotRef, verbose } } +} + +const parseDownload = (args: ReadonlyArray): ParseResult => { + const snapshotRef = extractSnapshotRef(args) + if (snapshotRef === null) { + return { _tag: "Error", message: "download requires " } + } + let outputDir = defaultOutputDir + let verbose = false + let index = 1 + while (index < args.length) { + const arg = args[index] + if (arg === "--output") { + const value = nextValue(args, index, arg) + if (typeof value !== "string") { + return value + } + outputDir = value + index += 2 + continue + } + if (arg === "--verbose") { + verbose = true + index += 1 + continue + } + return { _tag: "Error", message: `unknown download option ${arg ?? ""}` } + } + return { _tag: "Ok", command: { _tag: "Download", snapshotRef, outputDir, verbose } } +} + +export const parseArgs = (args: ReadonlyArray): ParseResult => { + const command = args[0] + if (command === undefined || command === "--help" || command === "-h" || command === "help") { + return { _tag: "Ok", command: { _tag: "Help" } } + } + const rest = args.slice(1) + if (command === "backup") { + return parseBackup(rest) + } + if (command === "list") { + return parseList(rest) + } + if (command === "view") { + return parseView(rest) + } + if (command === "download") { + return parseDownload(rest) + } + return { _tag: "Error", message: `unknown command ${command}` } +} + +const writeLine = (stream: NodeJS.WriteStream, message: string): void => { + stream.write(`${message}\n`) +} + +export const processOutput: Output = { + out: (message) => writeLine(process.stdout, message), + err: (message) => writeLine(process.stderr, message) +} + +export const runCli = ( + args: ReadonlyArray, + cwd: string, + output: Output = processOutput +): number => { + const parsed = parseArgs(args) + if (parsed._tag === "Error") { + output.err(parsed.message) + output.err(usageText) + return 1 + } + const command = parsed.command + if (command._tag === "Help") { + output.out(usageText) + return 0 + } + if (command._tag === "Backup") { + return backupSessions(command, cwd, output) + } + if (command._tag === "List") { + return listSnapshots(command, cwd, output) + } + if (command._tag === "View") { + return viewSnapshot(command, cwd, output) + } + return downloadSnapshot(command, cwd, output) +} diff --git a/packages/docker-git-session-sync/src/core.ts b/packages/docker-git-session-sync/src/core.ts new file mode 100644 index 00000000..94ac4f04 --- /dev/null +++ b/packages/docker-git-session-sync/src/core.ts @@ -0,0 +1,186 @@ +import path from "node:path" + +import type { + BackupRepo, + FileSummary, + SessionFile, + SnapshotManifest, + SnapshotManifestFile, + SourceInfo +} from "./types.js" + +export const backupRepoName = "docker-git-sessions" +export const backupDefaultBranch = "main" +export const chunkManifestSuffix = ".chunks.json" +export const maxRepoFileSize = 99 * 1000 * 1000 +export const maxPushBatchBytes = 50 * 1000 * 1000 +export const sessionDirNames: ReadonlyArray = [".codex", ".claude", ".qwen", ".gemini"] +export const sessionWalkIgnoreDirNames: ReadonlySet = new Set([".git", "node_modules", "tmp"]) +export const githubEnvKeys: ReadonlyArray = ["GITHUB_TOKEN", "GH_TOKEN"] + +export const toLogicalRelativePath = (relativePath: string): string => + relativePath.split(path.sep).join(path.posix.sep) + +export const shouldIgnoreSessionPath = (relativePath: string): boolean => { + const logicalPath = toLogicalRelativePath(relativePath) + return logicalPath === "tmp" || logicalPath.startsWith("tmp/") || logicalPath.includes("/tmp/") +} + +export const isPathWithinParent = (targetPath: string, parentPath: string): boolean => { + const relative = path.relative(parentPath, targetPath) + return relative === "" || (!relative.startsWith("..") && !path.isAbsolute(relative)) +} + +export const parseEnvText = (text: string): ReadonlyArray<{ readonly key: string; readonly value: string }> => { + const entries: Array<{ readonly key: string; readonly value: string }> = [] + for (const line of text.split(/\r?\n/u)) { + const match = line.match(/^([A-Z0-9_]+)=(.*)$/u) + if (match?.[1] !== undefined && match[2] !== undefined) { + entries.push({ key: match[1], value: match[2] }) + } + } + return entries +} + +export const findGithubTokenInEnvText = ( + text: string +): { readonly key: string; readonly token: string } | null => { + const entries = parseEnvText(text) + for (const key of githubEnvKeys) { + const entry = entries.find((item) => item.key === key) + const token = entry?.value.trim() ?? "" + if (token.length > 0) { + return { key, token } + } + } + return null +} + +export const buildBlobUrl = (repoFullName: string, branch: string, repoPath: string): string => + `https://github.com/${repoFullName}/blob/${encodeURIComponent(branch)}/${ + repoPath.split("/").map((segment) => encodeURIComponent(segment)).join("/") + }` + +export const toSnapshotStamp = (createdAt: string): string => + createdAt.replaceAll(":", "-").replaceAll(".", "-") + +export const buildSnapshotRef = ( + sourceRepo: string, + prNumber: number | null, + commitSha: string, + createdAt: string +): string => + `${sourceRepo}/pr-${prNumber === null ? "no-pr" : prNumber}/commit-${commitSha}/${toSnapshotStamp(createdAt)}` + +export const buildCommitMessage = (source: SourceInfo): string => + `session-backup: ${source.repo} ${source.branch} ${source.commitSha.slice(0, 12)} ${ + toSnapshotStamp(source.createdAt) + }` + +export const formatBytes = (bytes: number): string => { + if (bytes >= 1_000_000_000) { + return `${(bytes / 1_000_000_000).toFixed(2)} GB` + } + if (bytes >= 1_000_000) { + return `${(bytes / 1_000_000).toFixed(2)} MB` + } + if (bytes >= 1_000) { + return `${(bytes / 1_000).toFixed(2)} KB` + } + return `${bytes} B` +} + +export const summarizeFiles = (files: ReadonlyArray): FileSummary => ({ + fileCount: files.length, + totalBytes: files.reduce( + (sum, file) => sum + (file.type === "chunked" ? file.originalSize : file.size), + 0 + ) +}) + +export const buildManifest = (input: { + readonly backupRepo: BackupRepo + readonly snapshotRef: string + readonly source: SourceInfo + readonly files: ReadonlyArray + readonly createdAt: string +}): SnapshotManifest => ({ + version: 1, + createdAt: input.createdAt, + storage: { + repo: input.backupRepo.fullName, + branch: input.backupRepo.defaultBranch, + snapshotRef: input.snapshotRef + }, + source: input.source, + files: input.files +}) + +export const buildSnapshotReadme = (input: { + readonly backupRepo: BackupRepo + readonly source: SourceInfo + readonly manifestUrl: string + readonly summary: FileSummary + readonly sessionRoots: ReadonlyArray +}): string => + [ + "# AI Session Backup", + "", + "This snapshot contains AI session data used during development.", + "", + `- Backup Repo: \`${input.backupRepo.fullName}\``, + `- Source Repo: \`${input.source.repo}\``, + `- Source Branch: \`${input.source.branch}\``, + `- Source Commit: \`${input.source.commitSha}\``, + input.source.prNumber === null ? "- Pull Request: none" : `- Pull Request: #${input.source.prNumber}`, + `- Created At: \`${input.source.createdAt}\``, + `- Files: \`${input.summary.fileCount}\``, + `- Total Size: \`${formatBytes(input.summary.totalBytes)}\``, + `- Session Roots: \`${input.sessionRoots.join("`, `")}\``, + "", + `- Manifest: ${input.manifestUrl}`, + "", + "Generated automatically by the docker-git `git push` post-action.", + "" + ].join("\n") + +export const buildCommentBody = (input: { + readonly source: SourceInfo + readonly manifestUrl: string + readonly readmeUrl: string + readonly summary: FileSummary + readonly gitStatus: string | null +}): string => { + const statusText = input.gitStatus === null ? "(unavailable)" : input.gitStatus + return [ + "## AI Session Backup", + `Commit: ${input.source.commitSha}`, + `Files: ${input.summary.fileCount} (${formatBytes(input.summary.totalBytes)})`, + `Links: [README](${input.readmeUrl}) | [Manifest](${input.manifestUrl})`, + "", + "`git status`", + "```", + statusText, + "```", + `` + ].join("\n") +} + +export const sanitizeSnapshotRefForOutput = (snapshotRef: string): string => + snapshotRef.replace(/[\\/]/gu, "_") + +export const buildChunkManifest = ( + logicalName: string, + originalSize: number, + partNames: ReadonlyArray +) => ({ + original: logicalName, + originalSize, + parts: partNames, + splitAt: maxRepoFileSize, + partsCount: partNames.length, + createdAt: new Date().toISOString() +}) + +export const sortSessionFiles = (files: ReadonlyArray): ReadonlyArray => + files.slice().sort((left, right) => left.logicalName.localeCompare(right.logicalName)) diff --git a/packages/docker-git-session-sync/src/json.ts b/packages/docker-git-session-sync/src/json.ts new file mode 100644 index 00000000..58608945 --- /dev/null +++ b/packages/docker-git-session-sync/src/json.ts @@ -0,0 +1,37 @@ +export const errorMessage = (error: unknown): string => + error instanceof Error ? error.message : String(error) + +export const isRecord = (value: unknown): value is Readonly> => + typeof value === "object" && value !== null && !Array.isArray(value) + +export const stringField = (value: unknown, key: string): string | null => { + if (!isRecord(value)) { + return null + } + const field = value[key] + return typeof field === "string" ? field : null +} + +export const numberField = (value: unknown, key: string): number | null => { + if (!isRecord(value)) { + return null + } + const field = value[key] + return typeof field === "number" ? field : null +} + +export const recordField = (value: unknown, key: string): Readonly> | null => { + if (!isRecord(value)) { + return null + } + const field = value[key] + return isRecord(field) ? field : null +} + +export const arrayField = (value: unknown, key: string): ReadonlyArray => { + if (!isRecord(value)) { + return [] + } + const field = value[key] + return Array.isArray(field) ? field : [] +} diff --git a/packages/docker-git-session-sync/src/main.ts b/packages/docker-git-session-sync/src/main.ts new file mode 100644 index 00000000..dc7d8d7b --- /dev/null +++ b/packages/docker-git-session-sync/src/main.ts @@ -0,0 +1,19 @@ +import { NodeRuntime } from "@effect/platform-node" +import { Effect } from "effect" + +import { runCli } from "./cli.js" + +const program = Effect.sync(() => { + try { + const exitCode = runCli(process.argv.slice(2), process.cwd()) + if (exitCode !== 0) { + process.exitCode = exitCode + } + } catch (error) { + const message = error instanceof Error ? error.message : String(error) + process.stderr.write(`${message}\n`) + process.exitCode = 1 + } +}) + +NodeRuntime.runMain(program) diff --git a/packages/docker-git-session-sync/src/shell.ts b/packages/docker-git-session-sync/src/shell.ts new file mode 100644 index 00000000..aa86c057 --- /dev/null +++ b/packages/docker-git-session-sync/src/shell.ts @@ -0,0 +1,729 @@ +import fs from "node:fs" +import os from "node:os" +import path from "node:path" +import { spawnSync } from "node:child_process" + +import { + backupDefaultBranch, + backupRepoName, + buildBlobUrl, + buildChunkManifest, + buildCommitMessage, + chunkManifestSuffix, + findGithubTokenInEnvText, + githubEnvKeys, + maxPushBatchBytes, + maxRepoFileSize +} from "./core.js" +import { arrayField, errorMessage, isRecord, recordField, stringField } from "./json.js" +import type { + BackupRepo, + GhEnv, + Log, + PreparedUploadArtifacts, + SessionFile, + SnapshotManifest, + TreeEntry, + TreeSnapshot, + UploadEntry +} from "./types.js" + +const ghMaxBufferBytes = 32 * 1024 * 1024 +const ghGitCredentialHelper = "!gh auth git-credential" +const dockerGitConfigFile = "docker-git.json" +const projectWalkIgnoreDirNames: ReadonlySet = new Set([".git", "node_modules", ".cache", "tmp"]) + +interface CommandResult { + readonly success: boolean + readonly status: number + readonly stdout: string + readonly stderr: string +} + +interface GhJsonResult extends CommandResult { + readonly json: unknown +} + +interface TreeFileEntry { + readonly mode: string + readonly type: string + readonly sha: string +} + +interface NamedTreeEntry extends TreeFileEntry { + readonly name: string +} + +const commandResult = (status: number | null, stdout: string | Buffer, stderr: string | Buffer): CommandResult => ({ + success: status === 0, + status: status ?? 1, + stdout: stdout.toString().trim(), + stderr: stderr.toString().trim() +}) + +const ensureSuccess = (result: T, context: string): T => { + if (!result.success) { + throw new Error(`${context}: ${result.stderr || result.stdout || `exit ${result.status}`}`) + } + return result +} + +const ghCommand = ( + args: ReadonlyArray, + ghEnv: GhEnv, + inputFilePath: string | null = null +): CommandResult => { + const resolvedArgs = inputFilePath === null ? args : [...args, "--input", inputFilePath] + const result = spawnSync("gh", resolvedArgs, { + encoding: "utf8", + stdio: ["pipe", "pipe", "pipe"], + maxBuffer: ghMaxBufferBytes, + env: ghEnv + }) + return commandResult(result.status, result.stdout ?? "", result.stderr ?? "") +} + +const ghApi = ( + endpoint: string, + ghEnv: GhEnv, + options: { + readonly method?: string + readonly jq?: string + readonly rawFields?: Readonly> + readonly body?: unknown + } = {} +): CommandResult => { + const args = ["api", endpoint] + if (options.method !== undefined && options.method !== "GET") { + args.push("-X", options.method) + } + if (options.jq !== undefined) { + args.push("--jq", options.jq) + } + if (options.rawFields !== undefined) { + for (const [key, value] of Object.entries(options.rawFields)) { + args.push("-f", `${key}=${value}`) + } + } + if (options.body === undefined) { + return ghCommand(args, ghEnv) + } + + const inputFilePath = path.join(os.tmpdir(), `docker-git-gh-api-${Date.now()}-${Math.random().toString(16).slice(2)}.json`) + fs.writeFileSync(inputFilePath, JSON.stringify(options.body), "utf8") + try { + return ghCommand(args, ghEnv, inputFilePath) + } finally { + fs.rmSync(inputFilePath, { force: true }) + } +} + +const ghApiJson = (endpoint: string, ghEnv: GhEnv, options: Parameters[2] = {}): GhJsonResult => { + const result = ghApi(endpoint, ghEnv, options) + if (!result.success) { + return { ...result, json: null } + } + try { + const json: unknown = JSON.parse(result.stdout) + return { ...result, json } + } catch { + return { ...result, json: null } + } +} + +export const runGitCapture = ( + cwd: string, + args: ReadonlyArray, + env: GhEnv = process.env +): string | null => { + const result = spawnSync("git", args, { + cwd, + encoding: "utf8", + stdio: ["pipe", "pipe", "pipe"], + env + }) + return result.status === 0 ? (result.stdout ?? "").trim() : null +} + +const resolveViewerLogin = (ghEnv: GhEnv): string => + ensureSuccess(ghApi("/user", ghEnv, { jq: ".login" }), "failed to resolve authenticated GitHub login").stdout + +const getRepoInfo = (repoFullName: string, ghEnv: GhEnv): GhJsonResult => + ghApiJson(`/repos/${repoFullName}`, ghEnv) + +export const ensureBackupRepo = (ghEnv: GhEnv, log: Log, createIfMissing: boolean = true): BackupRepo | null => { + const login = resolveViewerLogin(ghEnv) + const repoFullName = `${login}/${backupRepoName}` + let repoResult = getRepoInfo(repoFullName, ghEnv) + if (!repoResult.success && createIfMissing) { + log(`Creating private session backup repository for ${login}...`) + repoResult = ghApiJson("/user/repos", ghEnv, { + method: "POST", + body: { + name: backupRepoName, + private: true, + auto_init: true, + description: "docker-git session backups" + } + }) + } + if (!repoResult.success || repoResult.json === null) { + return null + } + return { + owner: login, + repo: backupRepoName, + fullName: repoFullName, + defaultBranch: stringField(repoResult.json, "default_branch") ?? backupDefaultBranch, + htmlUrl: stringField(repoResult.json, "html_url") ?? `https://github.com/${repoFullName}` + } +} + +const getBranchHeadSha = (repoFullName: string, branch: string, ghEnv: GhEnv): string => + ensureSuccess( + ghApi(`/repos/${repoFullName}/git/ref/heads/${branch}`, ghEnv, { jq: ".object.sha" }), + `failed to resolve ${repoFullName}@${branch} ref` + ).stdout + +const getCommitTreeSha = (repoFullName: string, commitSha: string, ghEnv: GhEnv): string => + ensureSuccess( + ghApi(`/repos/${repoFullName}/git/commits/${commitSha}`, ghEnv, { jq: ".tree.sha" }), + `failed to resolve tree for commit ${commitSha}` + ).stdout + +const isTreeEntry = (value: unknown): value is TreeEntry => { + if (!isRecord(value)) { + return false + } + return ( + typeof value["path"] === "string" && + typeof value["mode"] === "string" && + typeof value["type"] === "string" && + typeof value["sha"] === "string" + ) +} + +export const getTreeEntries = (repoFullName: string, branch: string, ghEnv: GhEnv): TreeSnapshot => { + const headSha = getBranchHeadSha(repoFullName, branch, ghEnv) + const treeSha = getCommitTreeSha(repoFullName, headSha, ghEnv) + const result = ensureSuccess( + ghApiJson(`/repos/${repoFullName}/git/trees/${treeSha}?recursive=1`, ghEnv), + `failed to list tree for ${repoFullName}@${branch}` + ) + return { + headSha, + treeSha, + entries: arrayField(result.json, "tree").filter(isTreeEntry) + } +} + +const getTreeEntriesForCommit = (repoFullName: string, commitSha: string, ghEnv: GhEnv): TreeSnapshot => { + const treeSha = getCommitTreeSha(repoFullName, commitSha, ghEnv) + const result = ensureSuccess( + ghApiJson(`/repos/${repoFullName}/git/trees/${treeSha}?recursive=1`, ghEnv), + `failed to list tree for commit ${commitSha} in ${repoFullName}` + ) + return { + treeSha, + entries: arrayField(result.json, "tree").filter(isTreeEntry) + } +} + +export const getFileContent = ( + repoFullName: string, + repoPath: string, + ghEnv: GhEnv, + ref: string = backupDefaultBranch +): Buffer => { + const result = ensureSuccess( + ghApiJson(`/repos/${repoFullName}/contents/${repoPath}?ref=${encodeURIComponent(ref)}`, ghEnv), + `failed to fetch ${repoFullName}:${repoPath}` + ) + const encoding = stringField(result.json, "encoding") + const content = stringField(result.json, "content")?.replace(/\n/gu, "") ?? "" + if (encoding !== "base64" || content.length === 0) { + throw new Error(`unexpected content payload for ${repoFullName}:${repoPath}`) + } + return Buffer.from(content, "base64") +} + +const getDockerGitProjectsRoot = (): string => { + const configured = process.env["DOCKER_GIT_PROJECTS_ROOT"]?.trim() + return configured && configured.length > 0 ? configured : path.join(os.homedir(), ".docker-git") +} + +const readJsonFile = (filePath: string): unknown | null => { + try { + return JSON.parse(fs.readFileSync(filePath, "utf8")) + } catch { + return null + } +} + +const findDockerGitProjectForTarget = ( + projectsRoot: string, + targetDir: string, + log: Log +): { readonly configPath: string; readonly config: unknown } | null => { + if (!fs.existsSync(projectsRoot)) { + return null + } + const stack: Array = [projectsRoot] + while (stack.length > 0) { + const currentDir = stack.pop() + if (currentDir === undefined) { + continue + } + const configPath = path.join(currentDir, dockerGitConfigFile) + if (fs.existsSync(configPath)) { + const config = readJsonFile(configPath) + const candidateTarget = stringField(recordField(config, "template"), "targetDir") + if (candidateTarget === targetDir) { + log(`Resolved docker-git project config: ${configPath}`) + return { configPath, config } + } + } + let entries: ReadonlyArray = [] + try { + entries = fs.readdirSync(currentDir, { withFileTypes: true }) + } catch { + continue + } + for (const entry of entries) { + if (entry.isDirectory() && !projectWalkIgnoreDirNames.has(entry.name)) { + stack.push(path.join(currentDir, entry.name)) + } + } + } + return null +} + +const getGithubEnvFileCandidates = (repoRoot: string, log: Log): ReadonlyArray => { + const projectsRoot = getDockerGitProjectsRoot() + const candidates: Array = [] + const seen = new Set() + const project = findDockerGitProjectForTarget(projectsRoot, repoRoot, log) + const projectEnvGlobal = stringField(recordField(project?.config, "template"), "envGlobalPath") + if (project?.configPath !== undefined && projectEnvGlobal !== null && projectEnvGlobal.length > 0) { + const projectEnvPath = path.resolve(path.dirname(project.configPath), projectEnvGlobal) + candidates.push(projectEnvPath) + seen.add(projectEnvPath) + } + for (const candidate of [ + path.join(projectsRoot, ".orch", "env", "global.env"), + path.join(projectsRoot, "secrets", "global.env") + ]) { + if (!seen.has(candidate)) { + candidates.push(candidate) + seen.add(candidate) + } + } + return candidates +} + +export const resolveGhEnvironment = (repoRoot: string, log: Log): GhEnv => { + const env: GhEnv = { ...process.env } + for (const envPath of getGithubEnvFileCandidates(repoRoot, log)) { + if (!fs.existsSync(envPath)) { + continue + } + const resolved = findGithubTokenInEnvText(fs.readFileSync(envPath, "utf8")) + if (resolved !== null) { + log(`Using ${resolved.key} from ${envPath} for GitHub CLI auth`) + env["GH_TOKEN"] = resolved.token + env["GITHUB_TOKEN"] = resolved.token + return env + } + } + const fromProcess = githubEnvKeys.find((key) => { + const value = process.env[key]?.trim() ?? "" + return value.length > 0 + }) + log(fromProcess === undefined + ? "No GitHub token found in docker-git env files or current process" + : `Using ${fromProcess} from current process environment for GitHub CLI auth`) + return env +} + +const splitLargeFile = ( + sourcePath: string, + logicalName: string, + outputDir: string +): { readonly originalSize: number; readonly partNames: ReadonlyArray; readonly manifestName: string } => { + const totalSize = fs.statSync(sourcePath).size + const partNames: Array = [] + const fd = fs.openSync(sourcePath, "r") + const buffer = Buffer.alloc(1024 * 1024) + let offset = 0 + let remaining = totalSize + let partIndex = 1 + let partBytesWritten = 0 + let partName = `${logicalName}.part${partIndex}` + let partPath = path.join(outputDir, partName) + fs.mkdirSync(path.dirname(partPath), { recursive: true }) + let partFd = fs.openSync(partPath, "w") + partNames.push(partName) + + try { + while (remaining > 0) { + const bytesRead = fs.readSync(fd, buffer, 0, buffer.length, offset) + if (bytesRead === 0) { + break + } + let chunkOffset = 0 + while (chunkOffset < bytesRead) { + if (partBytesWritten >= maxRepoFileSize) { + fs.closeSync(partFd) + partIndex += 1 + partBytesWritten = 0 + partName = `${logicalName}.part${partIndex}` + partPath = path.join(outputDir, partName) + fs.mkdirSync(path.dirname(partPath), { recursive: true }) + partFd = fs.openSync(partPath, "w") + partNames.push(partName) + } + const remainingChunk = bytesRead - chunkOffset + const remainingPart = maxRepoFileSize - partBytesWritten + const toWrite = Math.min(remainingChunk, remainingPart) + fs.writeSync(partFd, buffer.subarray(chunkOffset, chunkOffset + toWrite)) + partBytesWritten += toWrite + chunkOffset += toWrite + } + offset += bytesRead + remaining -= bytesRead + } + } finally { + fs.closeSync(fd) + fs.closeSync(partFd) + } + return { + originalSize: totalSize, + partNames, + manifestName: `${logicalName}${chunkManifestSuffix}` + } +} + +export const prepareUploadArtifacts = ( + sessionFiles: ReadonlyArray, + snapshotRef: string, + repoFullName: string, + branch: string, + tmpDir: string, + log: Log +): PreparedUploadArtifacts => { + const uploadEntries: Array = [] + const manifestFiles: Array = [] + for (const file of sessionFiles) { + if (file.size <= maxRepoFileSize) { + const repoPath = `${snapshotRef}/${file.logicalName}` + uploadEntries.push({ repoPath, sourcePath: file.sourcePath, type: "file", size: file.size }) + manifestFiles.push({ + type: "file", + name: file.logicalName, + size: file.size, + repoPath, + url: buildBlobUrl(repoFullName, branch, repoPath) + }) + continue + } + log(`Splitting oversized file ${file.logicalName} (${file.size} bytes)`) + const split = splitLargeFile(file.sourcePath, file.logicalName, tmpDir) + const chunkManifest = buildChunkManifest(file.logicalName, split.originalSize, split.partNames) + const chunkManifestPath = path.join(tmpDir, split.manifestName) + fs.mkdirSync(path.dirname(chunkManifestPath), { recursive: true }) + fs.writeFileSync(chunkManifestPath, `${JSON.stringify(chunkManifest, null, 2)}\n`, "utf8") + const partEntries = split.partNames.map((partName) => { + const partPath = path.join(tmpDir, partName) + const repoPath = `${snapshotRef}/${partName}` + uploadEntries.push({ repoPath, sourcePath: partPath, type: "chunk-part", size: fs.statSync(partPath).size }) + return { name: partName, repoPath, url: buildBlobUrl(repoFullName, branch, repoPath) } + }) + const chunkManifestRepoPath = `${snapshotRef}/${split.manifestName}` + uploadEntries.push({ + repoPath: chunkManifestRepoPath, + sourcePath: chunkManifestPath, + type: "chunk-manifest", + size: fs.statSync(chunkManifestPath).size + }) + manifestFiles.push({ + type: "chunked", + name: file.logicalName, + originalSize: split.originalSize, + chunkManifestPath: chunkManifestRepoPath, + chunkManifestUrl: buildBlobUrl(repoFullName, branch, chunkManifestRepoPath), + parts: partEntries + }) + } + return { uploadEntries, manifestFiles } +} + +const splitUploadEntriesIntoBatches = (uploadEntries: ReadonlyArray): ReadonlyArray> => { + const batches: Array> = [] + let currentBatch: Array = [] + let currentBatchBytes = 0 + for (const entry of uploadEntries) { + if (currentBatch.length > 0 && currentBatchBytes + entry.size > maxPushBatchBytes) { + batches.push(currentBatch) + currentBatch = [] + currentBatchBytes = 0 + } + currentBatch.push(entry) + currentBatchBytes += entry.size + } + if (currentBatch.length > 0) { + batches.push(currentBatch) + } + return batches +} + +const runGitCommand = (repoDir: string, args: ReadonlyArray, env: GhEnv, input?: string): CommandResult => { + const result = spawnSync( + "git", + ["-c", "core.hooksPath=/dev/null", "-c", "protocol.version=2", "-C", repoDir, ...args], + { encoding: "utf8", stdio: ["pipe", "pipe", "pipe"], env, input } + ) + return commandResult(result.status, result.stdout ?? "", result.stderr ?? "") +} + +const buildGitPushEnv = (ghEnv: GhEnv, token: string): GhEnv => ({ + ...ghEnv, + GH_TOKEN: token, + GITHUB_TOKEN: token, + GIT_AUTH_TOKEN: token, + GIT_TERMINAL_PROMPT: "0" +}) + +const initializeUploadRepo = (repoDir: string, backupRepo: BackupRepo, gitEnv: GhEnv): void => { + ensureSuccess(runGitCommand(repoDir, ["init", "-q"], gitEnv), `failed to init git repo ${repoDir}`) + ensureSuccess( + runGitCommand(repoDir, ["remote", "add", "origin", `https://github.com/${backupRepo.fullName}.git`], gitEnv), + `failed to configure git remote for ${backupRepo.fullName}` + ) +} + +const fetchRemoteBranchTip = (repoDir: string, branch: string, gitEnv: GhEnv): string => { + ensureSuccess( + runGitCommand( + repoDir, + [ + "-c", + `credential.helper=${ghGitCredentialHelper}`, + "fetch", + "--quiet", + "--no-tags", + "--depth=1", + "--filter=blob:none", + "origin", + `refs/heads/${branch}:refs/remotes/origin/${branch}` + ], + gitEnv + ), + `failed to fetch ${branch} tip from backup repository` + ) + return ensureSuccess( + runGitCommand(repoDir, ["rev-parse", `refs/remotes/origin/${branch}`], gitEnv), + `failed to resolve fetched ${branch} tip` + ).stdout +} + +const hashFileObject = (repoDir: string, sourcePath: string, gitEnv: GhEnv): string => + ensureSuccess(runGitCommand(repoDir, ["hash-object", "-w", sourcePath], gitEnv), `failed to hash ${sourcePath}`).stdout + +const createTreeObject = (repoDir: string, entries: ReadonlyArray, gitEnv: GhEnv): string => { + const body = entries + .slice() + .sort((left, right) => left.name.localeCompare(right.name)) + .map((entry) => `${entry.mode} ${entry.type} ${entry.sha}\t${entry.name}`) + .join("\n") + return ensureSuccess( + runGitCommand(repoDir, ["mktree", "--missing"], gitEnv, body.length > 0 ? `${body}\n` : ""), + "failed to create git tree" + ).stdout +} + +const createCommitObject = ( + repoDir: string, + treeSha: string, + parentSha: string, + message: string, + createdAt: string, + owner: string, + gitEnv: GhEnv +): string => { + const authorEmail = `${owner}@users.noreply.github.com` + const unixSeconds = Math.floor(new Date(createdAt).getTime() / 1000) + const commitBody = [ + `tree ${treeSha}`, + `parent ${parentSha}`, + `author ${owner} <${authorEmail}> ${unixSeconds} +0000`, + `committer ${owner} <${authorEmail}> ${unixSeconds} +0000`, + "", + message, + "" + ].join("\n") + return ensureSuccess( + runGitCommand(repoDir, ["hash-object", "-t", "commit", "-w", "--stdin"], gitEnv, commitBody), + "failed to create git commit" + ).stdout +} + +const updateLocalRef = (repoDir: string, refName: string, commitSha: string, gitEnv: GhEnv): void => { + ensureSuccess(runGitCommand(repoDir, ["update-ref", refName, commitSha], gitEnv), `failed to update local ref ${refName}`) +} + +const isNonFastForwardPushError = (result: CommandResult): boolean => + /non-fast-forward|fetch first|rejected/iu.test(`${result.stderr}\n${result.stdout}`) + +const pushCommitToBranch = (repoDir: string, sourceRef: string, branch: string, gitEnv: GhEnv): CommandResult => + runGitCommand( + repoDir, + ["-c", `credential.helper=${ghGitCredentialHelper}`, "push", "origin", `${sourceRef}:refs/heads/${branch}`], + gitEnv + ) + +const buildFileMapFromTreeEntries = (entries: ReadonlyArray): Map => { + const fileMap = new Map() + for (const entry of entries) { + if (entry.type !== "tree") { + fileMap.set(entry.path, { mode: entry.mode, type: entry.type, sha: entry.sha }) + } + } + return fileMap +} + +const addChild = (childrenByDir: Map>, dirPath: string, child: NamedTreeEntry): void => { + const current = childrenByDir.get(dirPath) ?? [] + current.push(child) + childrenByDir.set(dirPath, current) +} + +const writeMergedTree = ( + repoDir: string, + existingEntries: ReadonlyArray, + newEntries: ReadonlyArray<{ readonly repoPath: string; readonly sha: string }>, + gitEnv: GhEnv +): string => { + const fileMap = buildFileMapFromTreeEntries(existingEntries) + for (const entry of newEntries) { + fileMap.set(entry.repoPath, { mode: "100644", type: "blob", sha: entry.sha }) + } + const directories = new Set([""]) + const childrenByDir = new Map>() + for (const [repoPath, entry] of fileMap.entries()) { + const segments = repoPath.split("/") + const name = segments.pop() + const dirPath = segments.join("/") + if (name === undefined || name.length === 0) { + continue + } + directories.add(dirPath) + for (let index = 1; index <= segments.length; index += 1) { + directories.add(segments.slice(0, index).join("/")) + } + addChild(childrenByDir, dirPath, { name, mode: entry.mode, type: entry.type, sha: entry.sha }) + } + const orderedDirectories = Array.from(directories).sort((left, right) => { + const depthDiff = right.split("/").length - left.split("/").length + return depthDiff !== 0 ? depthDiff : right.localeCompare(left) + }) + for (const dirPath of orderedDirectories) { + if (dirPath.length === 0) { + continue + } + const treeSha = createTreeObject(repoDir, childrenByDir.get(dirPath) ?? [], gitEnv) + const segments = dirPath.split("/") + const name = segments.pop() + if (name !== undefined && name.length > 0) { + addChild(childrenByDir, segments.join("/"), { name, mode: "040000", type: "tree", sha: treeSha }) + } + } + return createTreeObject(repoDir, childrenByDir.get("") ?? [], gitEnv) +} + +const buildUploadCommitMessage = (source: SnapshotManifest["source"], batchIndex: number, batchCount: number): string => + batchCount <= 1 ? buildCommitMessage(source) : `${buildCommitMessage(source)} [batch ${batchIndex}/${batchCount}]` + +export const uploadSnapshot = ( + backupRepo: BackupRepo, + snapshotRef: string, + snapshotManifest: SnapshotManifest, + uploadEntries: ReadonlyArray, + ghEnv: GhEnv +): { readonly commitSha: string; readonly manifestPath: string; readonly manifestUrl: string } => { + const token = ghEnv["GITHUB_TOKEN"]?.trim() || ghEnv["GH_TOKEN"]?.trim() || "" + if (token.length === 0) { + throw new Error("GitHub token missing for backup repository push") + } + const uploadRoot = fs.mkdtempSync(path.join(os.tmpdir(), "session-backup-git-push-")) + const manifestPath = `${snapshotRef}/manifest.json` + const manifestTempPath = path.join(uploadRoot, "manifest.json") + fs.writeFileSync(manifestTempPath, `${JSON.stringify(snapshotManifest, null, 2)}\n`, "utf8") + const manifestEntry = { + repoPath: manifestPath, + sourcePath: manifestTempPath, + size: fs.statSync(manifestTempPath).size + } + const uploadBatches = splitUploadEntriesIntoBatches([...uploadEntries, manifestEntry]) + try { + for (let attempt = 1; attempt <= 3; attempt += 1) { + const repoDir = path.join(uploadRoot, `attempt-${attempt}`, "repo") + fs.mkdirSync(repoDir, { recursive: true }) + const gitEnv = buildGitPushEnv(ghEnv, token) + initializeUploadRepo(repoDir, backupRepo, gitEnv) + let headSha = fetchRemoteBranchTip(repoDir, backupRepo.defaultBranch, gitEnv) + let existingEntries = getTreeEntriesForCommit(backupRepo.fullName, headSha, ghEnv).entries + let lastCommitSha = headSha + let shouldRetry = false + for (let batchIndex = 0; batchIndex < uploadBatches.length; batchIndex += 1) { + const batch = uploadBatches[batchIndex] ?? [] + const hashedEntries = batch.map((entry) => ({ + repoPath: entry.repoPath, + sha: hashFileObject(repoDir, entry.sourcePath, gitEnv) + })) + const nextTreeSha = writeMergedTree(repoDir, existingEntries, hashedEntries, gitEnv) + const commitSha = createCommitObject( + repoDir, + nextTreeSha, + headSha, + buildUploadCommitMessage(snapshotManifest.source, batchIndex + 1, uploadBatches.length), + snapshotManifest.source.createdAt, + backupRepo.owner, + gitEnv + ) + const localRef = `refs/heads/session-backup-upload-${attempt}-${batchIndex + 1}` + updateLocalRef(repoDir, localRef, commitSha, gitEnv) + const pushResult = pushCommitToBranch(repoDir, localRef, backupRepo.defaultBranch, gitEnv) + if (!pushResult.success) { + if (attempt < 3 && isNonFastForwardPushError(pushResult)) { + shouldRetry = true + break + } + throw new Error(`failed to push backup commit: ${pushResult.stderr || pushResult.stdout || `exit ${pushResult.status}`}`) + } + headSha = commitSha + lastCommitSha = commitSha + existingEntries = existingEntries.concat( + hashedEntries.map((entry) => ({ path: entry.repoPath, mode: "100644", type: "blob", sha: entry.sha })) + ) + } + if (!shouldRetry) { + return { + commitSha: lastCommitSha, + manifestPath, + manifestUrl: buildBlobUrl(backupRepo.fullName, backupRepo.defaultBranch, manifestPath) + } + } + } + throw new Error("failed to push backup commit after 3 attempts") + } finally { + fs.rmSync(uploadRoot, { recursive: true, force: true }) + } +} + +export const parseJsonBuffer = (buffer: Buffer, context: string): unknown => { + try { + return JSON.parse(buffer.toString("utf8")) + } catch (error) { + throw new Error(`failed to parse JSON for ${context}: ${errorMessage(error)}`) + } +} diff --git a/packages/docker-git-session-sync/src/snapshots.ts b/packages/docker-git-session-sync/src/snapshots.ts new file mode 100644 index 00000000..d525efca --- /dev/null +++ b/packages/docker-git-session-sync/src/snapshots.ts @@ -0,0 +1,191 @@ +import fs from "node:fs" +import path from "node:path" + +import { buildBlobUrl, sanitizeSnapshotRefForOutput } from "./core.js" +import { ensureBackupRepo, getFileContent, getTreeEntries, parseJsonBuffer, resolveGhEnvironment } from "./shell.js" +import type { BackupRepo, GhEnv, TreeEntry } from "./types.js" + +import type { Output } from "./backup.js" + +export interface ListOptions { + readonly limit: number + readonly repo: string | null + readonly verbose: boolean +} + +export interface ViewOptions { + readonly snapshotRef: string + readonly verbose: boolean +} + +export interface DownloadOptions { + readonly snapshotRef: string + readonly outputDir: string + readonly verbose: boolean +} + +const logVerbose = (verbose: boolean, output: Output, message: string): void => { + if (verbose) { + output.out(`[session-backups] ${message}`) + } +} + +const ensureBackupRepoOrExit = (ghEnv: GhEnv, verbose: boolean, output: Output): BackupRepo | null => { + const backupRepo = ensureBackupRepo(ghEnv, (message) => logVerbose(verbose, output, message), false) + if (backupRepo === null) { + output.out("No private session backup repository found.") + } + return backupRepo +} + +const getManifestRepoPath = (snapshotRef: string): string => `${snapshotRef}/manifest.json` + +const fetchManifest = (backupRepo: BackupRepo, snapshotRef: string, ghEnv: GhEnv): { + readonly path: string + readonly data: unknown +} => { + const manifestPath = getManifestRepoPath(snapshotRef) + return { + path: manifestPath, + data: parseJsonBuffer(getFileContent(backupRepo.fullName, manifestPath, ghEnv, backupRepo.defaultBranch), manifestPath) + } +} + +const isManifestPathEntry = (entry: TreeEntry): boolean => + entry.type === "blob" && entry.path.endsWith("/manifest.json") + +const sourceField = (manifestData: unknown, key: string): string => { + if (typeof manifestData !== "object" || manifestData === null || Array.isArray(manifestData)) { + return "" + } + const source = Reflect.get(manifestData, "source") + if (typeof source !== "object" || source === null || Array.isArray(source)) { + return "" + } + const field = Reflect.get(source, key) + return typeof field === "string" ? field : "" +} + +const createdAtField = (manifestData: unknown): string => { + if (typeof manifestData !== "object" || manifestData === null || Array.isArray(manifestData)) { + return "" + } + const field = Reflect.get(manifestData, "createdAt") + return typeof field === "string" ? field : "" +} + +export const listSnapshots = (options: ListOptions, cwd: string, output: Output): number => { + const ghEnv = resolveGhEnvironment(cwd, (message) => logVerbose(options.verbose, output, message)) + const backupRepo = ensureBackupRepoOrExit(ghEnv, options.verbose, output) + if (backupRepo === null) { + return 0 + } + + logVerbose(options.verbose, output, `Listing snapshots from ${backupRepo.fullName}`) + const manifestPaths = getTreeEntries(backupRepo.fullName, backupRepo.defaultBranch, ghEnv).entries + .filter(isManifestPathEntry) + .map((entry) => entry.path) + const filtered = options.repo === null + ? manifestPaths + : manifestPaths.filter((entryPath) => entryPath.startsWith(`${options.repo}/`)) + + if (filtered.length === 0) { + output.out("No session snapshots found.") + if (options.repo !== null) { + output.out(`(Filtered by repo: ${options.repo})`) + } + return 0 + } + + output.out("Session Snapshots:\n") + for (const manifestPath of filtered.slice(0, options.limit)) { + const snapshotRef = manifestPath.slice(0, -"/manifest.json".length) + const manifest = fetchManifest(backupRepo, snapshotRef, ghEnv) + output.out(snapshotRef) + output.out(` Source: ${sourceField(manifest.data, "repo")}`) + output.out(` Commit: ${sourceField(manifest.data, "commitSha")}`) + output.out(` Created: ${createdAtField(manifest.data)}`) + output.out(` Manifest: ${buildBlobUrl(backupRepo.fullName, backupRepo.defaultBranch, manifest.path)}`) + output.out("") + } + output.out(`Total: ${filtered.length} snapshot(s)`) + return 0 +} + +export const viewSnapshot = (options: ViewOptions, cwd: string, output: Output): number => { + const ghEnv = resolveGhEnvironment(cwd, (message) => logVerbose(options.verbose, output, message)) + const backupRepo = ensureBackupRepoOrExit(ghEnv, options.verbose, output) + if (backupRepo === null) { + return 0 + } + logVerbose(options.verbose, output, `Viewing snapshot: ${options.snapshotRef}`) + const manifest = fetchManifest(backupRepo, options.snapshotRef, ghEnv) + output.out(JSON.stringify(manifest.data, null, 2)) + return 0 +} + +const manifestFiles = (manifestData: unknown): ReadonlyArray => { + if (typeof manifestData !== "object" || manifestData === null || Array.isArray(manifestData)) { + return [] + } + const files = Reflect.get(manifestData, "files") + return Array.isArray(files) ? files : [] +} + +const fileString = (file: unknown, key: string): string | null => { + if (typeof file !== "object" || file === null || Array.isArray(file)) { + return null + } + const field = Reflect.get(file, key) + return typeof field === "string" ? field : null +} + +const fileParts = (file: unknown): ReadonlyArray => { + if (typeof file !== "object" || file === null || Array.isArray(file)) { + return [] + } + const parts = Reflect.get(file, "parts") + return Array.isArray(parts) ? parts : [] +} + +export const downloadSnapshot = (options: DownloadOptions, cwd: string, output: Output): number => { + const ghEnv = resolveGhEnvironment(cwd, (message) => logVerbose(options.verbose, output, message)) + const backupRepo = ensureBackupRepoOrExit(ghEnv, options.verbose, output) + if (backupRepo === null) { + return 0 + } + logVerbose(options.verbose, output, `Downloading snapshot ${options.snapshotRef} to ${options.outputDir}`) + const manifest = fetchManifest(backupRepo, options.snapshotRef, ghEnv) + const outputPath = path.resolve(options.outputDir, sanitizeSnapshotRefForOutput(options.snapshotRef)) + fs.mkdirSync(outputPath, { recursive: true }) + fs.writeFileSync(path.join(outputPath, "manifest.json"), `${JSON.stringify(manifest.data, null, 2)}\n`, "utf8") + + for (const file of manifestFiles(manifest.data)) { + const name = fileString(file, "name") + if (name === null) { + continue + } + const targetPath = path.join(outputPath, name) + fs.mkdirSync(path.dirname(targetPath), { recursive: true }) + if (fileString(file, "type") === "chunked") { + const buffers = fileParts(file) + .map((part) => fileString(part, "repoPath")) + .filter((repoPath): repoPath is string => repoPath !== null) + .map((repoPath) => getFileContent(backupRepo.fullName, repoPath, ghEnv, backupRepo.defaultBranch)) + fs.writeFileSync(targetPath, Buffer.concat(buffers)) + continue + } + const repoPath = fileString(file, "repoPath") + if (repoPath !== null) { + fs.writeFileSync(targetPath, getFileContent(backupRepo.fullName, repoPath, ghEnv, backupRepo.defaultBranch)) + } + } + + output.out(`Downloaded snapshot to: ${outputPath}`) + output.out("\nTo restore session files, copy them to the appropriate location:") + output.out(" - .codex/... -> ~/.codex/") + output.out(" - .claude/... -> ~/.claude/") + output.out(" - .qwen/... -> ~/.qwen/") + output.out(" - .gemini/... -> ~/.gemini/") + return 0 +} diff --git a/packages/docker-git-session-sync/src/types.ts b/packages/docker-git-session-sync/src/types.ts new file mode 100644 index 00000000..0635e887 --- /dev/null +++ b/packages/docker-git-session-sync/src/types.ts @@ -0,0 +1,92 @@ +export type Log = (message: string) => void + +export type GhEnv = NodeJS.ProcessEnv + +export interface BackupRepo { + readonly owner: string + readonly repo: string + readonly fullName: string + readonly defaultBranch: string + readonly htmlUrl: string +} + +export interface SessionFile { + readonly logicalName: string + readonly sourcePath: string + readonly size: number +} + +export interface UploadEntry { + readonly repoPath: string + readonly sourcePath: string + readonly type?: string + readonly size: number +} + +export interface SourceInfo { + readonly repo: string + readonly branch: string + readonly prNumber: number | null + readonly commitSha: string + readonly createdAt: string +} + +export interface ManifestFile { + readonly type: "file" + readonly name: string + readonly size: number + readonly repoPath: string + readonly url: string +} + +export interface ChunkedManifestPart { + readonly name: string + readonly repoPath: string + readonly url: string +} + +export interface ChunkedManifestFile { + readonly type: "chunked" + readonly name: string + readonly originalSize: number + readonly chunkManifestPath: string + readonly chunkManifestUrl: string + readonly parts: ReadonlyArray +} + +export type SnapshotManifestFile = ManifestFile | ChunkedManifestFile + +export interface SnapshotManifest { + readonly version: 1 + readonly createdAt: string + readonly storage: { + readonly repo: string + readonly branch: string + readonly snapshotRef: string + } + readonly source: SourceInfo + readonly files: ReadonlyArray +} + +export interface PreparedUploadArtifacts { + readonly uploadEntries: ReadonlyArray + readonly manifestFiles: ReadonlyArray +} + +export interface FileSummary { + readonly fileCount: number + readonly totalBytes: number +} + +export interface TreeEntry { + readonly path: string + readonly mode: string + readonly type: string + readonly sha: string +} + +export interface TreeSnapshot { + readonly headSha?: string + readonly treeSha: string + readonly entries: ReadonlyArray +} diff --git a/packages/docker-git-session-sync/tests/session-files.test.ts b/packages/docker-git-session-sync/tests/session-files.test.ts new file mode 100644 index 00000000..2ef10c61 --- /dev/null +++ b/packages/docker-git-session-sync/tests/session-files.test.ts @@ -0,0 +1,76 @@ +import fs from "node:fs" +import os from "node:os" +import path from "node:path" +import { afterEach, beforeEach, describe, expect, it } from "vitest" + +import { shouldIgnoreSessionPath } from "../src/core.js" +import { collectSessionFiles, type Output } from "../src/backup.js" +import { parseArgs } from "../src/cli.js" + +const output: Output = { + out: () => undefined, + err: () => undefined +} + +let tmpDir = "" + +beforeEach(() => { + tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), "docker-git-session-sync-test-")) +}) + +afterEach(() => { + fs.rmSync(tmpDir, { recursive: true, force: true }) +}) + +describe("session path filtering", () => { + it("ignores tmp directories while keeping persistent session files", () => { + const codexDir = path.join(tmpDir, ".codex") + const claudeDir = path.join(tmpDir, ".claude") + fs.mkdirSync(path.join(codexDir, "tmp"), { recursive: true }) + fs.mkdirSync(path.join(codexDir, "memory"), { recursive: true }) + fs.mkdirSync(path.join(claudeDir, "profiles"), { recursive: true }) + fs.writeFileSync(path.join(codexDir, "history.jsonl"), "{}\n") + fs.writeFileSync(path.join(codexDir, "tmp", "session.lock"), "lock") + fs.writeFileSync(path.join(codexDir, "memory", "notes.md"), "# notes\n") + fs.writeFileSync(path.join(claudeDir, "profiles", "default.json"), "{}") + + const logicalNames = [ + ...collectSessionFiles(codexDir, ".codex", false, output), + ...collectSessionFiles(claudeDir, ".claude", false, output) + ].map((file) => file.logicalName) + + expect(logicalNames).toContain(".codex/history.jsonl") + expect(logicalNames).toContain(".codex/memory/notes.md") + expect(logicalNames).toContain(".claude/profiles/default.json") + expect(logicalNames).not.toContain(".codex/tmp/session.lock") + }) + + it("treats nested tmp segments as ignored paths", () => { + expect(shouldIgnoreSessionPath("tmp")).toBe(true) + expect(shouldIgnoreSessionPath("tmp/session.lock")).toBe(true) + expect(shouldIgnoreSessionPath("memory/tmp/session.lock")).toBe(true) + expect(shouldIgnoreSessionPath("memory/notes.md")).toBe(false) + }) +}) + +describe("CLI parser", () => { + it("parses backup options for PR comments", () => { + expect(parseArgs(["backup", "--repo", "org/repo", "--pr-number", "42", "--no-comment"])).toEqual({ + _tag: "Ok", + command: { + _tag: "Backup", + sessionDir: null, + prNumber: 42, + repo: "org/repo", + postComment: false, + dryRun: false, + verbose: false + } + }) + }) + + it("rejects missing snapshot refs", () => { + expect(parseArgs(["view"])).toEqual({ _tag: "Error", message: "view requires " }) + expect(parseArgs(["download"])).toEqual({ _tag: "Error", message: "download requires " }) + }) +}) diff --git a/packages/docker-git-session-sync/tsconfig.json b/packages/docker-git-session-sync/tsconfig.json new file mode 100644 index 00000000..eb355b16 --- /dev/null +++ b/packages/docker-git-session-sync/tsconfig.json @@ -0,0 +1,11 @@ +{ + "extends": "../../tsconfig.base.json", + "compilerOptions": { + "rootDir": ".", + "outDir": "dist", + "types": ["vitest", "node"], + "baseUrl": "." + }, + "include": ["src/**/*", "tests/**/*", "vite.config.ts"], + "exclude": ["dist", "node_modules"] +} diff --git a/packages/docker-git-session-sync/vite.config.ts b/packages/docker-git-session-sync/vite.config.ts new file mode 100644 index 00000000..33df01d7 --- /dev/null +++ b/packages/docker-git-session-sync/vite.config.ts @@ -0,0 +1,22 @@ +import { defineConfig } from "vite" + +export default defineConfig({ + publicDir: false, + build: { + target: "node20", + outDir: "dist", + sourcemap: true, + ssr: "src/main.ts", + rollupOptions: { + output: { + banner: "#!/usr/bin/env bun", + entryFileNames: "docker-git-session-sync.js", + format: "es" + } + }, + ssrEmitAssets: false + }, + ssr: { + target: "node" + } +}) diff --git a/packages/docker-git-session-sync/vitest.config.ts b/packages/docker-git-session-sync/vitest.config.ts new file mode 100644 index 00000000..d2fc918f --- /dev/null +++ b/packages/docker-git-session-sync/vitest.config.ts @@ -0,0 +1,9 @@ +import { defineConfig } from "vitest/config" + +export default defineConfig({ + test: { + environment: "node", + globals: true, + include: ["tests/**/*.test.ts"] + } +}) diff --git a/packages/lib/package.json b/packages/lib/package.json index 22552263..1c885ccd 100644 --- a/packages/lib/package.json +++ b/packages/lib/package.json @@ -50,6 +50,7 @@ "ts-morph": "^27.0.2" }, "devDependencies": { + "@prover-coder-ai/docker-git-session-sync": "workspace:*", "@biomejs/biome": "^2.4.8", "@effect/eslint-plugin": "^0.3.2", "@effect/language-service": "latest", diff --git a/packages/lib/src/core/docker-git-scripts.ts b/packages/lib/src/core/docker-git-scripts.ts index 0c028c56..6ea07b17 100644 --- a/packages/lib/src/core/docker-git-scripts.ts +++ b/packages/lib/src/core/docker-git-scripts.ts @@ -1,5 +1,5 @@ // CHANGE: define the set of docker-git scripts to embed in generated containers -// WHY: scripts (session-backup, pre-commit guards, knowledge splitter) must be available +// WHY: scripts (pre-commit guards, knowledge splitter) must be available // inside containers for git hooks and docker-git module usage // REF: issue-176 // SOURCE: n/a @@ -11,8 +11,7 @@ /** * Names of docker-git scripts that must be available inside generated containers. * - * These scripts are referenced by git hooks (pre-push, pre-commit), the global - * git push post-action runtime, and session backup workflows. They are copied into + * These scripts are referenced by git hooks (pre-push, pre-commit). They are copied into * each project's build context under * `scripts/` and embedded into the Docker image at `/opt/docker-git/scripts/`. * @@ -20,9 +19,6 @@ * @invariant ∀ name ∈ result: ∃ file(scripts/{name}) in docker-git workspace */ export const dockerGitScriptNames: ReadonlyArray = [ - "session-backup-gist.js", - "session-backup-repo.js", - "session-list-gists.js", "pre-commit-secret-guard.sh", "pre-push-knowledge-guard.js", "split-knowledge-large-files.js", diff --git a/packages/lib/src/core/domain.ts b/packages/lib/src/core/domain.ts index cd0acd49..8d3d3fb7 100644 --- a/packages/lib/src/core/domain.ts +++ b/packages/lib/src/core/domain.ts @@ -195,14 +195,6 @@ export interface DownAllCommand { readonly _tag: "DownAll" } -export type { - SessionGistBackupCommand, - SessionGistCommand, - SessionGistDownloadCommand, - SessionGistListCommand, - SessionGistViewCommand -} from "./session-gist-domain.js" - export type ScrapCommand = | ScrapExportCommand | ScrapImportCommand diff --git a/packages/lib/src/core/session-gist-domain.ts b/packages/lib/src/core/session-gist-domain.ts deleted file mode 100644 index 3cb6bfc7..00000000 --- a/packages/lib/src/core/session-gist-domain.ts +++ /dev/null @@ -1,36 +0,0 @@ -// CHANGE: session backup commands for PR-based session history -// WHY: enables returning to old AI sessions via a private backup repository -// QUOTE(ТЗ): "иметь возможность возвращаться ко всем старым сессиям с агентами" -// REF: issue-143 -// PURITY: CORE - -export interface SessionGistBackupCommand { - readonly _tag: "SessionGistBackup" - readonly projectDir: string - readonly prNumber: number | null - readonly repo: string | null - readonly postComment: boolean -} - -export interface SessionGistListCommand { - readonly _tag: "SessionGistList" - readonly limit: number - readonly repo: string | null -} - -export interface SessionGistViewCommand { - readonly _tag: "SessionGistView" - readonly snapshotRef: string -} - -export interface SessionGistDownloadCommand { - readonly _tag: "SessionGistDownload" - readonly snapshotRef: string - readonly outputDir: string -} - -export type SessionGistCommand = - | SessionGistBackupCommand - | SessionGistListCommand - | SessionGistViewCommand - | SessionGistDownloadCommand diff --git a/packages/lib/src/core/sessions-domain.ts b/packages/lib/src/core/sessions-domain.ts index 19ed0cd4..40d50d50 100644 --- a/packages/lib/src/core/sessions-domain.ts +++ b/packages/lib/src/core/sessions-domain.ts @@ -1,5 +1,3 @@ -import type { SessionGistCommand } from "./session-gist-domain.js" - export interface SessionsListCommand { readonly _tag: "SessionsList" readonly projectDir: string @@ -23,4 +21,3 @@ export type SessionsCommand = | SessionsListCommand | SessionsKillCommand | SessionsLogsCommand - | SessionGistCommand diff --git a/packages/lib/src/core/templates-entrypoint/git.ts b/packages/lib/src/core/templates-entrypoint/git.ts index 270b0757..10ad4459 100644 --- a/packages/lib/src/core/templates-entrypoint/git.ts +++ b/packages/lib/src/core/templates-entrypoint/git.ts @@ -279,16 +279,10 @@ cd "$REPO_ROOT" # REF: issue-192 if [ "${"${"}DOCKER_GIT_SKIP_SESSION_BACKUP:-}" != "1" ]; then if command -v gh >/dev/null 2>&1; then - BACKUP_SCRIPT="" - if [ -f "$REPO_ROOT/scripts/session-backup-gist.js" ]; then - BACKUP_SCRIPT="$REPO_ROOT/scripts/session-backup-gist.js" - elif [ -f /opt/docker-git/scripts/session-backup-gist.js ]; then - BACKUP_SCRIPT="/opt/docker-git/scripts/session-backup-gist.js" - fi - if [ -n "$BACKUP_SCRIPT" ]; then - DOCKER_GIT_SKIP_POST_PUSH_ACTION=1 node "$BACKUP_SCRIPT" || echo "[session-backup] Warning: session backup failed (non-fatal)" + if command -v docker-git-session-sync >/dev/null 2>&1; then + DOCKER_GIT_SKIP_POST_PUSH_ACTION=1 docker-git-session-sync backup --verbose || echo "[session-backup] Warning: session backup failed (non-fatal)" else - echo "[session-backup] Warning: script not found (expected repo or global path)" + echo "[session-backup] Warning: docker-git-session-sync not found (skipping session backup)" fi else echo "[session-backup] Warning: gh CLI not found (skipping session backup)" diff --git a/packages/lib/src/core/templates-entrypoint/tasks.ts b/packages/lib/src/core/templates-entrypoint/tasks.ts index 09d2e067..ed3465d3 100644 --- a/packages/lib/src/core/templates-entrypoint/tasks.ts +++ b/packages/lib/src/core/templates-entrypoint/tasks.ts @@ -193,7 +193,7 @@ const renderCloneBody = (config: TemplateConfig): string => ].join("\n") // CHANGE: provision docker-git scripts into workspace after successful clone -// WHY: git hooks reference scripts/ relative to repo root (e.g. "bun scripts/session-backup-gist.js"); +// WHY: git hooks reference scripts/ relative to repo root; // symlinking embedded /opt/docker-git/scripts makes them available in any cloned repo // REF: issue-176 // PURITY: SHELL diff --git a/packages/lib/src/core/templates.ts b/packages/lib/src/core/templates.ts index 35c6521e..b1cb76d9 100644 --- a/packages/lib/src/core/templates.ts +++ b/packages/lib/src/core/templates.ts @@ -13,8 +13,9 @@ const renderGitignore = (): string => `# docker-git project files # NOTE: bootstrap secrets stay local-only and should not be committed. -# docker-git scripts (copied from workspace, rebuilt on each project update) +# docker-git scripts/tools (copied from workspace, rebuilt on each project update) scripts/ +.docker-git-tools/ # Volatile Codex artifacts (do not commit) authorized_keys diff --git a/packages/lib/src/core/templates/dockerfile.ts b/packages/lib/src/core/templates/dockerfile.ts index 88a4c2bc..a507842a 100644 --- a/packages/lib/src/core/templates/dockerfile.ts +++ b/packages/lib/src/core/templates/dockerfile.ts @@ -263,17 +263,20 @@ RUN printf "%s\\n" \ "AllowUsers ${config.sshUser}" \ > /etc/ssh/sshd_config.d/${config.sshUser}.conf` -// CHANGE: add docker-git scripts to Docker image at /opt/docker-git/scripts -// WHY: scripts (session-backup, pre-commit guards, knowledge splitter) must be available -// inside the container for git hooks and docker-git module usage +// CHANGE: add docker-git scripts and session sync tool to Docker image +// WHY: git hooks need embedded scripts, while session sync is provided by a standalone tool // REF: issue-176 // PURITY: CORE (pure template renderer) -// INVARIANT: ∀ script ∈ scripts/: accessible(/opt/docker-git/scripts/{script}) +// INVARIANT: scripts are accessible under /opt/docker-git/scripts and session sync under PATH const renderDockerfileScripts = (): string => - `# docker-git scripts (hooks, session backup, knowledge guards) + `# docker-git scripts (hooks, knowledge guards) COPY scripts/ /opt/docker-git/scripts/ RUN find /opt/docker-git/scripts -type f -name '*.sh' -exec chmod +x {} + \ - && find /opt/docker-git/scripts -type f -name '*.js' -exec chmod +x {} +` + && find /opt/docker-git/scripts -type f -name '*.js' -exec chmod +x {} + + +# docker-git standalone tools +COPY .docker-git-tools/docker-git-session-sync /usr/local/bin/docker-git-session-sync +RUN chmod +x /usr/local/bin/docker-git-session-sync` const renderDockerfileWorkspace = (config: TemplateConfig): string => `# Workspace path (supports root-level dirs like /repo) diff --git a/packages/lib/src/shell/files.ts b/packages/lib/src/shell/files.ts index fdb3aa23..c26ca884 100644 --- a/packages/lib/src/shell/files.ts +++ b/packages/lib/src/shell/files.ts @@ -1,6 +1,8 @@ import type { PlatformError } from "@effect/platform/Error" import type * as FileSystem from "@effect/platform/FileSystem" import type * as Path from "@effect/platform/Path" +import { createRequire } from "node:module" +import nodePath from "node:path" import { Effect, Match } from "effect" import { dockerGitScriptNames } from "../core/docker-git-scripts.js" @@ -14,6 +16,9 @@ import { resolveWorkspaceRoot } from "./workspace-root.js" const ensureParentDir = (path: Path.Path, fs: FileSystem.FileSystem, filePath: string) => fs.makeDirectory(path.dirname(filePath), { recursive: true }) +const require = createRequire(import.meta.url) +const sessionSyncToolRelativePath = ".docker-git-tools/docker-git-session-sync" + const fallbackHostResources = { cpuCount: 1, totalMemoryBytes: 1024 ** 3 @@ -139,6 +144,54 @@ const provisionDockerGitScripts = ( } }) +const resolveInstalledSessionSyncTool = (): string | null => { + try { + const packageJsonPath = require.resolve("@prover-coder-ai/docker-git-session-sync/package.json") + return nodePath.join(nodePath.dirname(packageJsonPath), "dist", "docker-git-session-sync.js") + } catch { + return null + } +} + +const sessionSyncToolCandidates = (path: Path.Path, workspaceRoot: string): ReadonlyArray => { + const installed = resolveInstalledSessionSyncTool() + const workspaceCandidate = path.join( + workspaceRoot, + "packages", + "docker-git-session-sync", + "dist", + "docker-git-session-sync.js" + ) + return installed === null ? [workspaceCandidate] : [workspaceCandidate, installed] +} + +// CHANGE: provision standalone session sync tool into the Docker build context +// WHY: generated containers call docker-git-session-sync directly after git push +// REF: issue-230 +// PURITY: SHELL +// EFFECT: Effect +// INVARIANT: target executable exists before Dockerfile COPY is evaluated +// COMPLEXITY: O(k) where k = candidate tool locations +const provisionDockerGitSessionSyncTool = ( + fs: FileSystem.FileSystem, + path: Path.Path, + baseDir: string +): Effect.Effect => + Effect.gen(function*(_) { + const workspaceRoot = yield* _(resolveWorkspaceRoot(process.cwd())) + const targetPath = path.join(baseDir, sessionSyncToolRelativePath) + for (const sourcePath of sessionSyncToolCandidates(path, workspaceRoot)) { + const exists = yield* _(fs.exists(sourcePath)) + if (exists) { + const contents = yield* _(fs.readFileString(sourcePath)) + yield* _(ensureParentDir(path, fs, targetPath)) + yield* _(fs.writeFileString(targetPath, contents, { mode: 0o755 })) + return + } + } + yield* _(Effect.dieMessage("docker-git-session-sync build artifact not found; run bun run --cwd packages/docker-git-session-sync build")) + }) + // CHANGE: write generated docker-git files to disk // WHY: isolate all filesystem effects in a thin shell // QUOTE(ТЗ): "создавать докер образы" @@ -191,6 +244,7 @@ export const writeProjectFiles = ( // WHY: Dockerfile COPY scripts/ requires scripts to be in the build context // REF: issue-176 yield* _(provisionDockerGitScripts(fs, path, baseDir)) + yield* _(provisionDockerGitSessionSyncTool(fs, path, baseDir)) return created }) diff --git a/packages/lib/src/usecases/session-gists.ts b/packages/lib/src/usecases/session-gists.ts deleted file mode 100644 index e29c1a5a..00000000 --- a/packages/lib/src/usecases/session-gists.ts +++ /dev/null @@ -1,92 +0,0 @@ -import type * as CommandExecutor from "@effect/platform/CommandExecutor" -import type { PlatformError } from "@effect/platform/Error" -import { Effect } from "effect" - -import type { - SessionGistBackupCommand, - SessionGistDownloadCommand, - SessionGistListCommand, - SessionGistViewCommand -} from "../core/domain.js" -import { runCommandWithExitCodes } from "../shell/command-runner.js" -import { CommandFailedError } from "../shell/errors.js" - -// CHANGE: implement session backup repository operations via shell commands -// WHY: enables CLI access to session backup/list/view/download functionality -// QUOTE(ТЗ): "иметь возможность возвращаться ко всем старым сессиям с агентами" -// REF: issue-143 -// PURITY: SHELL -// EFFECT: Effect -// INVARIANT: all operations require gh CLI authentication -// COMPLEXITY: O(n) where n = number of files/gists - -type SessionGistsError = CommandFailedError | PlatformError -type SessionGistsRequirements = CommandExecutor.CommandExecutor - -const nodeOk = [0] - -const makeNodeSpec = (scriptPath: string, args: ReadonlyArray) => ({ - cwd: process.cwd(), - command: "node", - args: [scriptPath, ...args] -}) - -const runNodeScript = ( - scriptPath: string, - args: ReadonlyArray -): Effect.Effect => - runCommandWithExitCodes( - makeNodeSpec(scriptPath, args), - nodeOk, - (exitCode) => new CommandFailedError({ command: `node ${scriptPath}`, exitCode }) - ) - -export const sessionGistBackup = ( - cmd: SessionGistBackupCommand -): Effect.Effect => { - const args: Array = ["--verbose"] - if (cmd.prNumber !== null) { - args.push("--pr-number", cmd.prNumber.toString()) - } - if (cmd.repo !== null) { - args.push("--repo", cmd.repo) - } - if (!cmd.postComment) { - args.push("--no-comment") - } - return Effect.gen(function*(_) { - yield* _(Effect.log("Backing up AI session to private session repository...")) - yield* _(runNodeScript("scripts/session-backup-gist.js", args)) - yield* _(Effect.log("Session backup complete.")) - }) -} - -export const sessionGistList = ( - cmd: SessionGistListCommand -): Effect.Effect => { - const args: Array = ["list", "--limit", cmd.limit.toString()] - if (cmd.repo !== null) { - args.push("--repo", cmd.repo) - } - return Effect.gen(function*(_) { - yield* _(Effect.log("Listing session backup snapshots...")) - yield* _(runNodeScript("scripts/session-list-gists.js", args)) - }) -} - -export const sessionGistView = ( - cmd: SessionGistViewCommand -): Effect.Effect => - Effect.gen(function*(_) { - yield* _(Effect.log(`Viewing snapshot: ${cmd.snapshotRef}`)) - yield* _(runNodeScript("scripts/session-list-gists.js", ["view", cmd.snapshotRef])) - }) - -export const sessionGistDownload = ( - cmd: SessionGistDownloadCommand -): Effect.Effect => - Effect.gen(function*(_) { - yield* _(Effect.log(`Downloading snapshot ${cmd.snapshotRef} to ${cmd.outputDir}...`)) - yield* _(runNodeScript("scripts/session-list-gists.js", ["download", cmd.snapshotRef, "--output", cmd.outputDir])) - yield* _(Effect.log("Download complete.")) - }) diff --git a/packages/lib/tests/core/git-post-push-wrapper.test.ts b/packages/lib/tests/core/git-post-push-wrapper.test.ts index 421761d8..19b0b13d 100644 --- a/packages/lib/tests/core/git-post-push-wrapper.test.ts +++ b/packages/lib/tests/core/git-post-push-wrapper.test.ts @@ -84,7 +84,7 @@ fi exit 0 ` -const fakeNodeScript = `#!/usr/bin/env bash +const fakeSessionSyncScript = `#!/usr/bin/env bash set -euo pipefail if [[ -n "\${FAKE_NODE_CWD_LOG_PATH:-}" ]]; then @@ -94,7 +94,7 @@ if [[ -n "\${FAKE_NODE_REPO_ROOT_LOG_PATH:-}" ]]; then printf '%s\\n' "\${DOCKER_GIT_POST_PUSH_REPO_ROOT:-}" >> "$FAKE_NODE_REPO_ROOT_LOG_PATH" fi if [[ -n "\${FAKE_NODE_SCRIPT_LOG_PATH:-}" ]]; then - printf '%s\\n' "$1" >> "$FAKE_NODE_SCRIPT_LOG_PATH" + printf '%s\\n' "$*" >> "$FAKE_NODE_SCRIPT_LOG_PATH" fi exit 0 @@ -242,16 +242,14 @@ const withHarness = ( const nodeScriptLogPath = path.join(rootDir, "node-script.log") yield* _(fs.makeDirectory(path.join(repoDir, ".git"), { recursive: true })) - yield* _(fs.makeDirectory(path.join(repoDir, "scripts"), { recursive: true })) yield* _(fs.makeDirectory(externalDir, { recursive: true })) yield* _(fs.makeDirectory(binDir, { recursive: true })) yield* _(fs.makeDirectory(hooksDir, { recursive: true })) - yield* _(fs.writeFileString(path.join(repoDir, "scripts", "session-backup-gist.js"), "// test placeholder\n")) yield* _(writeExecutable(path.join(binDir, "git"), fakeGitScript)) yield* _(writeExecutable(path.join(binDir, "git-real"), fakeGitScript)) yield* _(writeExecutable(path.join(binDir, "gh"), fakeGhScript)) - yield* _(writeExecutable(path.join(binDir, "node"), fakeNodeScript)) + yield* _(writeExecutable(path.join(binDir, "docker-git-session-sync"), fakeSessionSyncScript)) const postPushScript = extractEmbeddedScript(renderEntrypointGitHooks(), "$POST_PUSH_ACTION") const postPushPath = path.join(hooksDir, "post-push") @@ -294,7 +292,7 @@ describe("git post-push wrapper", () => { expect(nodeCwd).toEqual([harness.repoDir]) expect(nodeRepoRoot).toEqual([harness.repoDir]) - expect(nodeScript).toEqual([`${harness.repoDir}/scripts/session-backup-gist.js`]) + expect(nodeScript).toEqual(["backup --verbose"]) }) ).pipe(Effect.provide(NodeContext.layer))) @@ -310,7 +308,7 @@ describe("git post-push wrapper", () => { expect(nodeCwd).toEqual([harness.repoDir]) expect(nodeRepoRoot).toEqual([harness.repoDir]) - expect(nodeScript).toEqual([`${harness.repoDir}/scripts/session-backup-gist.js`]) + expect(nodeScript).toEqual(["backup --verbose"]) expect(gitLog.some((line) => line.startsWith(`${harness.externalDir}\t-C ${harness.repoDir} push`))).toBe(true) }) ).pipe(Effect.provide(NodeContext.layer))) diff --git a/packages/lib/tests/core/templates.test.ts b/packages/lib/tests/core/templates.test.ts index 84fdf9f0..2d5bb757 100644 --- a/packages/lib/tests/core/templates.test.ts +++ b/packages/lib/tests/core/templates.test.ts @@ -76,11 +76,10 @@ describe("renderEntrypointGitHooks", () => { expect(hooks).not.toContain('POST_PUSH_RUNTIME="/etc/profile.d/zz-git-post-push.sh"') expect(hooks).not.toContain("source /etc/profile.d/zz-git-post-push.sh") expect(hooks).toContain('REPO_ROOT="${DOCKER_GIT_POST_PUSH_REPO_ROOT:-}"') - expect(hooks).toContain("node \"$BACKUP_SCRIPT\"") - expect(hooks).not.toContain("node \"$BACKUP_SCRIPT\" --verbose") - expect(hooks.indexOf('$REPO_ROOT/scripts/session-backup-gist.js')).toBeLessThan( - hooks.indexOf("/opt/docker-git/scripts/session-backup-gist.js") - ) + expect(hooks).toContain("docker-git-session-sync backup --verbose") + expect(hooks).toContain("docker-git-session-sync not found") + expect(hooks).not.toContain("node \"$BACKUP_SCRIPT\"") + expect(hooks).not.toContain("session-backup-gist.js") expect(hooks).toContain("[session-backup] Warning: gh CLI not found") }) }) diff --git a/packages/lib/tests/usecases/prepare-files.test.ts b/packages/lib/tests/usecases/prepare-files.test.ts index bd4fc225..b553e83c 100644 --- a/packages/lib/tests/usecases/prepare-files.test.ts +++ b/packages/lib/tests/usecases/prepare-files.test.ts @@ -324,21 +324,24 @@ describe("prepareProjectFiles", () => { }) ).pipe(Effect.provide(NodeContext.layer))) - it.effect("copies docker-git scripts from the workspace root when cwd is a nested package", () => + it.effect("copies docker-git scripts and session sync tool from the workspace root when cwd is a nested package", () => withTempDir((root) => Effect.gen(function*(_) { const fs = yield* _(FileSystem.FileSystem) const path = yield* _(Path.Path) const packageDir = path.join(root, "packages", "api") const scriptsDir = path.join(root, "scripts") + const toolPath = path.join(root, "packages", "docker-git-session-sync", "dist", "docker-git-session-sync.js") const outDir = path.join(root, "project-with-scripts") const globalConfig = makeGlobalConfig(root, path) const projectConfig = makeProjectConfig(outDir, false, path) yield* _(fs.makeDirectory(packageDir, { recursive: true })) yield* _(fs.makeDirectory(scriptsDir, { recursive: true })) + yield* _(fs.makeDirectory(path.dirname(toolPath), { recursive: true })) yield* _(fs.writeFileString(path.join(root, "bunfig.toml"), "[install]\nlinkWorkspacePackages = true\n")) - yield* _(fs.writeFileString(path.join(scriptsDir, "session-backup-gist.js"), "#!/usr/bin/env bun\n")) + yield* _(fs.writeFileString(path.join(scriptsDir, "pre-commit-secret-guard.sh"), "#!/usr/bin/env bash\n")) + yield* _(fs.writeFileString(toolPath, "#!/usr/bin/env bun\n")) yield* _( withWorkingDirectory( @@ -350,7 +353,8 @@ describe("prepareProjectFiles", () => { ) ) - expect(yield* _(fs.exists(path.join(outDir, "scripts", "session-backup-gist.js")))).toBe(true) + expect(yield* _(fs.exists(path.join(outDir, "scripts", "pre-commit-secret-guard.sh")))).toBe(true) + expect(yield* _(fs.exists(path.join(outDir, ".docker-git-tools", "docker-git-session-sync")))).toBe(true) }) ).pipe(Effect.provide(NodeContext.layer))) diff --git a/pnpm-workspace.yaml b/pnpm-workspace.yaml index 3c77a1ee..773eb65d 100644 --- a/pnpm-workspace.yaml +++ b/pnpm-workspace.yaml @@ -1,4 +1,5 @@ packages: - packages/api - packages/app + - packages/docker-git-session-sync - packages/lib diff --git a/scripts/e2e/local-package-cli.sh b/scripts/e2e/local-package-cli.sh index 7f627d18..42a44934 100755 --- a/scripts/e2e/local-package-cli.sh +++ b/scripts/e2e/local-package-cli.sh @@ -11,9 +11,11 @@ ROOT="$(mktemp -d "$ROOT_BASE/local-package-cli.XXXXXX")" KEEP="${KEEP:-0}" PACK_LOG="$ROOT/bun-pack.log" +SESSION_PACK_LOG="$ROOT/bun-pack-session-sync.log" HELP_LOG_BUN="$ROOT/docker-git-help-bun.log" TAR_LIST="$ROOT/tar-list.txt" PACKED_TARBALL="" +SESSION_PACKED_TARBALL="" PACKAGE_JSON="$REPO_ROOT/packages/app/package.json" PACKAGE_JSON_BACKUP="$ROOT/package.json.backup" @@ -29,6 +31,10 @@ on_error() { echo "--- bun pack log ---" >&2 cat "$PACK_LOG" >&2 || true fi + if [[ -f "$SESSION_PACK_LOG" ]]; then + echo "--- bun pack session sync log ---" >&2 + cat "$SESSION_PACK_LOG" >&2 || true + fi if [[ -f "$HELP_LOG_BUN" ]]; then echo "--- bun run docker-git --help log ---" >&2 cat "$HELP_LOG_BUN" >&2 || true @@ -46,6 +52,9 @@ cleanup() { if [[ -n "$PACKED_TARBALL" ]] && [[ -f "$PACKED_TARBALL" ]]; then rm -f "$PACKED_TARBALL" >/dev/null 2>&1 || true fi + if [[ -n "$SESSION_PACKED_TARBALL" ]] && [[ -f "$SESSION_PACKED_TARBALL" ]]; then + rm -f "$SESSION_PACKED_TARBALL" >/dev/null 2>&1 || true + fi rm -rf "$ROOT" >/dev/null 2>&1 || true } @@ -54,8 +63,14 @@ trap cleanup EXIT dg_prepare_docker_git_cli "$REPO_ROOT" "$ROOT/.e2e-bin" +cd "$REPO_ROOT/packages/docker-git-session-sync" +bun run build >/dev/null +SESSION_PACKED_TARBALL="$(bun pm pack --quiet --ignore-scripts --destination "$ROOT" | tee "$SESSION_PACK_LOG" | tail -n 1 | tr -d '\r')" +[[ -n "$SESSION_PACKED_TARBALL" ]] || fail "bun pm pack did not return session sync tarball path" +[[ -f "$SESSION_PACKED_TARBALL" ]] || fail "packed session sync tarball not found: $SESSION_PACKED_TARBALL" + cp "$PACKAGE_JSON" "$PACKAGE_JSON_BACKUP" -bun -e 'import { readFileSync, writeFileSync } from "node:fs"; const path = process.argv[1]; const pkg = JSON.parse(readFileSync(path, "utf8")); delete pkg.devDependencies; writeFileSync(path, JSON.stringify(pkg, null, 2) + "\n");' "$PACKAGE_JSON" +SESSION_PACKED_TARBALL="$SESSION_PACKED_TARBALL" bun -e 'import { readFileSync, writeFileSync } from "node:fs"; const path = process.argv[1]; const pkg = JSON.parse(readFileSync(path, "utf8")); delete pkg.devDependencies; pkg.dependencies = pkg.dependencies ?? {}; pkg.dependencies["@prover-coder-ai/docker-git-session-sync"] = `file:${process.env.SESSION_PACKED_TARBALL}`; writeFileSync(path, JSON.stringify(pkg, null, 2) + "\n");' "$PACKAGE_JSON" cd "$REPO_ROOT/packages/app" PACKED_TARBALL="$(bun pm pack --quiet --ignore-scripts --destination "$ROOT" | tee "$PACK_LOG" | tail -n 1 | tr -d '\r')" diff --git a/scripts/session-backup-gist.js b/scripts/session-backup-gist.js deleted file mode 100644 index a87c08de..00000000 --- a/scripts/session-backup-gist.js +++ /dev/null @@ -1,686 +0,0 @@ -#!/usr/bin/env bun - -/** - * Session Backup to a private GitHub repository - * - * This script backs up AI agent session files (~/.codex, ~/.claude, ~/.qwen, ~/.gemini) - * to a dedicated private repository and optionally posts a comment to the - * associated PR with direct links to the uploaded files. - * - * Usage: - * bun scripts/session-backup-gist.js [options] - * - * Options: - * --session-dir Path to session directory under $HOME (default: auto-detect ~/.codex, ~/.claude, ~/.qwen, or ~/.gemini) - * --pr-number Open PR number to post comment to (optional, auto-detected from branch) - * --repo Source repository (optional, auto-detected from git remote) - * --no-comment Skip posting PR comment - * --dry-run Show what would be uploaded without actually uploading - * --verbose Enable verbose logging - * - * Environment: - * DOCKER_GIT_SKIP_SESSION_BACKUP=1 Skip session backup entirely - * - * @pure false - contains IO effects (file system, network, git commands) - * @effect FileSystem, ProcessExec, GitHubRepo - */ - -const fs = require("node:fs"); -const path = require("node:path"); -const { execSync, spawnSync } = require("node:child_process"); -const os = require("node:os"); -const GH_MAX_BUFFER_BYTES = 32 * 1024 * 1024; - -const { - buildBlobUrl, - buildSnapshotRef, - ensureBackupRepo, - resolveGhEnvironment, - prepareUploadArtifacts, - uploadSnapshot, -} = require("./session-backup-repo.js"); - -const SESSION_DIR_NAMES = [".codex", ".claude", ".qwen", ".gemini"]; -const SESSION_WALK_IGNORE_DIR_NAMES = new Set([".git", "node_modules", "tmp"]); - -const toLogicalRelativePath = (relativePath) => - relativePath.split(path.sep).join(path.posix.sep); - -const shouldIgnoreSessionPath = (relativePath) => { - const logicalPath = toLogicalRelativePath(relativePath); - return logicalPath === "tmp" || logicalPath.startsWith("tmp/") || logicalPath.includes("/tmp/"); -}; - -const isPathWithinParent = (targetPath, parentPath) => { - const relative = path.relative(parentPath, targetPath); - return relative === "" || (!relative.startsWith("..") && !path.isAbsolute(relative)); -}; - -const getAllowedSessionRoots = () => { - const homeDir = os.homedir(); - return SESSION_DIR_NAMES.map((dirName) => ({ - name: dirName, - path: path.join(homeDir, dirName), - })).filter((entry) => fs.existsSync(entry.path)); -}; - -const resolveAllowedSessionDir = (candidatePath, verbose) => { - const resolvedPath = path.resolve(candidatePath); - if (!fs.existsSync(resolvedPath)) { - return null; - } - - for (const root of getAllowedSessionRoots()) { - if (isPathWithinParent(resolvedPath, root.path)) { - return resolvedPath; - } - } - - log(verbose, `Skipping non-session directory: ${candidatePath}`); - return null; -}; - -const parseArgs = () => { - const args = process.argv.slice(2); - const result = { - sessionDir: null, - prNumber: null, - repo: null, - postComment: true, - dryRun: false, - verbose: false, - }; - - for (let i = 0; i < args.length; i++) { - const arg = args[i]; - switch (arg) { - case "--session-dir": - result.sessionDir = args[++i]; - break; - case "--pr-number": - result.prNumber = parseInt(args[++i], 10); - break; - case "--repo": - result.repo = args[++i]; - break; - case "--no-comment": - result.postComment = false; - break; - case "--dry-run": - result.dryRun = true; - break; - case "--verbose": - result.verbose = true; - break; - case "--help": - console.log(`Usage: session-backup-gist.js [options] - -Options: - --session-dir Path to session directory under $HOME - --pr-number Open PR number to post comment to - --repo Source repository - --no-comment Skip posting PR comment - --dry-run Show what would be uploaded - --verbose Enable verbose logging - --help Show this help message`); - process.exit(0); - } - } - - return result; -}; - -const log = (verbose, message) => { - if (verbose) { - console.log(`[session-backup] ${message}`); - } -}; - -const execCommand = (command, options = {}) => { - try { - return execSync(command, { - encoding: "utf8", - stdio: ["pipe", "pipe", "pipe"], - ...options, - }).trim(); - } catch { - return null; - } -}; - -const getGitStatus = () => { - const status = execCommand("git status"); - if (status === null) { - return null; - } - if (!status) { - return "clean"; - } - return status; -}; - -const printGitStatus = (status) => { - console.log("[session-backup] git status:"); - if (status === null) { - console.log("[session-backup] (unavailable)"); - return; - } - - for (const line of status.split("\n")) { - console.log(`[session-backup] ${line}`); - } -}; - -const ghCommand = (args, ghEnv) => { - const result = spawnSync("gh", args, { - encoding: "utf8", - stdio: ["pipe", "pipe", "pipe"], - maxBuffer: GH_MAX_BUFFER_BYTES, - env: ghEnv, - }); - - return { - success: result.status === 0, - stdout: (result.stdout || "").trim(), - stderr: (result.stderr || "").trim(), - }; -}; - -const parseGitHubRepoFromRemoteUrl = (remoteUrl) => { - if (!remoteUrl) { - return null; - } - - const sshMatch = remoteUrl.match(/git@github\.com:([^/]+\/[^.]+)(?:\.git)?$/); - if (sshMatch) { - return sshMatch[1]; - } - - const httpsMatch = remoteUrl.match(/https:\/\/github\.com\/([^/]+\/[^.]+)(?:\.git)?$/); - if (httpsMatch) { - return httpsMatch[1]; - } - - return null; -}; - -const rankRemoteName = (remoteName) => { - if (remoteName === "upstream") { - return 0; - } - if (remoteName === "origin") { - return 1; - } - return 2; -}; - -const getCurrentBranch = () => execCommand("git rev-parse --abbrev-ref HEAD"); - -const getHeadCommitSha = () => execCommand("git rev-parse HEAD"); - -const getRepoCandidates = (explicitRepo, verbose) => { - if (explicitRepo) { - return [explicitRepo]; - } - - const remoteOutput = execCommand("git remote -v"); - if (!remoteOutput) { - return []; - } - - const remotes = []; - const seenRepos = new Set(); - - for (const line of remoteOutput.split("\n")) { - const match = line.match(/^(\S+)\s+(\S+)\s+\((fetch|push)\)$/); - if (!match || match[3] !== "fetch") { - continue; - } - - const [, remoteName, remoteUrl] = match; - const repo = parseGitHubRepoFromRemoteUrl(remoteUrl); - if (!repo || seenRepos.has(repo)) { - continue; - } - - remotes.push({ remoteName, repo }); - seenRepos.add(repo); - } - - remotes.sort((left, right) => { - const rankDiff = rankRemoteName(left.remoteName) - rankRemoteName(right.remoteName); - return rankDiff !== 0 ? rankDiff : left.remoteName.localeCompare(right.remoteName); - }); - - const repos = remotes.map(({ repo }) => repo); - if (repos.length > 0) { - log(verbose, `Repository candidates: ${repos.join(", ")}`); - } - return repos; -}; - -const getPrNumberFromBranch = (repo, branch, ghEnv) => { - const result = ghCommand([ - "pr", - "list", - "--repo", - repo, - "--head", - branch, - "--json", - "number", - "--jq", - ".[0].number", - ], ghEnv); - - if (result.success && result.stdout && !Number.isNaN(parseInt(result.stdout, 10))) { - return parseInt(result.stdout, 10); - } - return null; -}; - -const getPrState = (repo, prNumber, ghEnv) => { - const result = ghCommand([ - "pr", - "view", - prNumber.toString(), - "--repo", - repo, - "--json", - "state", - "--jq", - ".state", - ], ghEnv); - - return result.success ? result.stdout : null; -}; - -const prIsOpen = (repo, prNumber, ghEnv) => { - return getPrState(repo, prNumber, ghEnv) === "OPEN"; -}; - -const getPrNumberFromWorkspaceBranch = (branch) => { - const match = branch.match(/^pr-refs-pull-([0-9]+)-head$/); - if (!match) { - return null; - } - - const prNumber = parseInt(match[1], 10); - return Number.isNaN(prNumber) ? null : prNumber; -}; - -const findPrContext = (repos, branch, verbose, ghEnv) => { - for (const repo of repos) { - log(verbose, `Checking open PR in ${repo} for branch ${branch}`); - const prNumber = getPrNumberFromBranch(repo, branch, ghEnv); - if (prNumber !== null && prIsOpen(repo, prNumber, ghEnv)) { - return { repo, prNumber }; - } - if (prNumber !== null) { - log(verbose, `Skipping PR #${prNumber} in ${repo}: PR is not open`); - } - } - - const workspacePrNumber = getPrNumberFromWorkspaceBranch(branch); - if (workspacePrNumber === null) { - return null; - } - - for (const repo of repos) { - log(verbose, `Checking workspace PR #${workspacePrNumber} in ${repo} for branch ${branch}`); - if (prIsOpen(repo, workspacePrNumber, ghEnv)) { - return { repo, prNumber: workspacePrNumber }; - } - } - - return null; -}; - -const findSessionDirs = (explicitPath, verbose) => { - const dirs = []; - - if (explicitPath) { - const allowedPath = resolveAllowedSessionDir(path.resolve(explicitPath), verbose); - if (allowedPath === null) { - console.error( - `[session-backup] --session-dir must point to a directory under ${SESSION_DIR_NAMES - .map((dirName) => `~/${dirName}`) - .join(", ")}` - ); - process.exit(1); - } - dirs.push({ name: path.basename(allowedPath), path: allowedPath }); - return dirs; - } - - for (const root of getAllowedSessionRoots()) { - const allowedPath = resolveAllowedSessionDir(root.path, verbose); - if (allowedPath !== null) { - log(verbose, `Found session directory: ${allowedPath}`); - dirs.push({ name: root.name, path: allowedPath }); - } - } - - return dirs; -}; - -const collectSessionFiles = (dirPath, baseName, verbose) => { - const files = []; - - const walk = (currentPath, relativePath) => { - const entries = fs.readdirSync(currentPath, { withFileTypes: true }); - - for (const entry of entries) { - const fullPath = path.join(currentPath, entry.name); - const relPath = relativePath ? `${relativePath}/${entry.name}` : entry.name; - const logicalRelPath = toLogicalRelativePath(relPath); - - if (shouldIgnoreSessionPath(logicalRelPath)) { - log(verbose, `Skipping tmp path: ${path.posix.join(baseName, logicalRelPath)}`); - continue; - } - - if (entry.isDirectory()) { - if (SESSION_WALK_IGNORE_DIR_NAMES.has(entry.name)) { - continue; - } - walk(fullPath, relPath); - } else if (entry.isFile()) { - try { - const stats = fs.statSync(fullPath); - const logicalName = path.posix.join(baseName, logicalRelPath); - files.push({ - logicalName, - sourcePath: fullPath, - size: stats.size, - }); - log(verbose, `Collected file: ${logicalName} (${stats.size} bytes)`); - } catch (error) { - log(verbose, `Error reading file ${fullPath}: ${error.message}`); - } - } - } - }; - - walk(dirPath, ""); - return files; -}; - -const buildManifest = ({ backupRepo, snapshotRef, source, files, createdAt }) => ({ - version: 1, - createdAt, - storage: { - repo: backupRepo.fullName, - branch: backupRepo.defaultBranch, - snapshotRef, - }, - source, - files, -}); - -const formatBytes = (bytes) => { - if (bytes >= 1_000_000_000) { - return `${(bytes / 1_000_000_000).toFixed(2)} GB`; - } - if (bytes >= 1_000_000) { - return `${(bytes / 1_000_000).toFixed(2)} MB`; - } - if (bytes >= 1_000) { - return `${(bytes / 1_000).toFixed(2)} KB`; - } - return `${bytes} B`; -}; - -const summarizeFiles = (files) => ({ - fileCount: files.length, - totalBytes: files.reduce( - (sum, file) => sum + (file.type === "chunked" ? (file.originalSize ?? 0) : (file.size ?? 0)), - 0 - ), -}); - -const buildSnapshotReadme = ({ backupRepo, source, manifestUrl, summary, sessionRoots }) => - [ - "# AI Session Backup", - "", - "This snapshot contains AI session data used during development.", - "", - `- Backup Repo: \`${backupRepo.fullName}\``, - `- Source Repo: \`${source.repo}\``, - `- Source Branch: \`${source.branch}\``, - `- Source Commit: \`${source.commitSha}\``, - source.prNumber === null ? "- Pull Request: none" : `- Pull Request: #${source.prNumber}`, - `- Created At: \`${source.createdAt}\``, - `- Files: \`${summary.fileCount}\``, - `- Total Size: \`${formatBytes(summary.totalBytes)}\``, - `- Session Roots: \`${sessionRoots.join("`, `")}\``, - "", - `- Manifest: ${manifestUrl}`, - "", - "Generated automatically by the docker-git `git push` post-action.", - "", - ].join("\n"); - -const buildCommentBody = ({ source, manifestUrl, readmeUrl, summary, gitStatus }) => { - const statusText = gitStatus === null ? "(unavailable)" : gitStatus; - const lines = [ - "## AI Session Backup", - `Commit: ${source.commitSha}`, - `Files: ${summary.fileCount} (${formatBytes(summary.totalBytes)})`, - `Links: [README](${readmeUrl}) | [Manifest](${manifestUrl})`, - "", - "`git status`", - "```", - statusText, - "```", - ]; - - lines.push(``); - return lines.join("\n"); -}; - -const postPrComment = (repo, prNumber, comment, verbose, ghEnv) => { - log(verbose, `Posting comment to PR #${prNumber}`); - - const result = ghCommand([ - "pr", - "comment", - prNumber.toString(), - "--repo", - repo, - "--body", - comment, - ], ghEnv); - - if (!result.success) { - console.error(`[session-backup] Failed to post PR comment: ${result.stderr}`); - return false; - } - - log(verbose, "Comment posted successfully"); - return true; -}; - -const main = () => { - if (process.env.DOCKER_GIT_SKIP_SESSION_BACKUP === "1") { - console.log("[session-backup] Skipped (DOCKER_GIT_SKIP_SESSION_BACKUP=1)"); - return; - } - - const args = parseArgs(); - const verbose = args.verbose; - const ghEnv = resolveGhEnvironment(process.cwd(), (message) => log(verbose, message)); - - log(verbose, "Starting session backup..."); - - const repoCandidates = getRepoCandidates(args.repo, verbose); - if (repoCandidates.length === 0) { - console.error("[session-backup] Could not determine source repository. Use --repo option."); - process.exit(1); - } - const sourceRepo = repoCandidates[0]; - log(verbose, `Repository: ${sourceRepo}`); - - const branch = getCurrentBranch(); - if (!branch) { - console.error("[session-backup] Could not determine current branch."); - process.exit(1); - } - log(verbose, `Branch: ${branch}`); - - const commitSha = getHeadCommitSha(); - if (!commitSha) { - console.error("[session-backup] Could not determine current commit."); - process.exit(1); - } - - let prContext = null; - if (args.prNumber !== null) { - if (prIsOpen(sourceRepo, args.prNumber, ghEnv)) { - prContext = { repo: sourceRepo, prNumber: args.prNumber }; - } else { - log(verbose, `Skipping PR comment: PR #${args.prNumber} is not open`); - } - } else if (args.postComment) { - prContext = findPrContext(repoCandidates, branch, verbose, ghEnv); - } - - if (prContext !== null) { - log(verbose, `PR number: ${prContext.prNumber} (${prContext.repo})`); - } else if (args.postComment) { - log(verbose, "No PR found for current branch, skipping comment"); - } - - const sessionDirs = findSessionDirs(args.sessionDir, verbose); - if (sessionDirs.length === 0) { - log(verbose, "No session directories found"); - return; - } - - const sessionFiles = []; - for (const dir of sessionDirs) { - sessionFiles.push(...collectSessionFiles(dir.path, dir.name, verbose)); - } - - if (sessionFiles.length === 0) { - log(verbose, "No session files found to backup"); - return; - } - log(verbose, `Total files to backup: ${sessionFiles.length}`); - - const backupRepo = ensureBackupRepo(ghEnv, (message) => log(verbose, message), !args.dryRun); - if (backupRepo === null) { - console.error("[session-backup] Failed to resolve or create the private session backup repository"); - process.exit(1); - } - - const tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), "session-backup-repo-")); - - try { - const snapshotCreatedAt = new Date().toISOString(); - const snapshotRef = buildSnapshotRef(sourceRepo, prContext?.prNumber ?? null, commitSha, snapshotCreatedAt); - const prepared = prepareUploadArtifacts( - sessionFiles, - snapshotRef, - backupRepo.fullName, - backupRepo.defaultBranch, - tmpDir, - (message) => log(verbose, message) - ); - - const source = { - repo: sourceRepo, - branch, - prNumber: prContext?.prNumber ?? null, - commitSha, - createdAt: snapshotCreatedAt, - }; - const summary = summarizeFiles(prepared.manifestFiles); - const sessionRoots = sessionDirs.map((dir) => `~/${dir.name}`); - const manifestUrl = buildBlobUrl(backupRepo.fullName, backupRepo.defaultBranch, `${snapshotRef}/manifest.json`); - const readmeRepoPath = `${snapshotRef}/README.md`; - const readmeUrl = buildBlobUrl(backupRepo.fullName, backupRepo.defaultBranch, readmeRepoPath); - const gitStatus = getGitStatus(); - - const manifest = buildManifest({ - backupRepo, - snapshotRef, - source, - files: prepared.manifestFiles, - createdAt: snapshotCreatedAt, - }); - const readmePath = path.join(tmpDir, "README.md"); - fs.writeFileSync( - readmePath, - buildSnapshotReadme({ - backupRepo, - source, - manifestUrl, - summary, - sessionRoots, - }), - "utf8" - ); - const uploadEntries = [ - ...prepared.uploadEntries, - { - repoPath: readmeRepoPath, - sourcePath: readmePath, - type: "readme", - size: fs.statSync(readmePath).size, - }, - ]; - if (args.dryRun) { - console.log( - `[session-backup] dry-run: ${source.commitSha.slice(0, 12)} (${summary.fileCount} files, ${formatBytes(summary.totalBytes)})` - ); - printGitStatus(gitStatus); - log(verbose, `[dry-run] Upload target: ${backupRepo.fullName}:${snapshotRef}`); - log(verbose, `[dry-run] README URL: ${readmeUrl}`); - log(verbose, `[dry-run] Manifest URL: ${manifestUrl}`); - if (args.postComment && prContext !== null) { - log(verbose, `Would post comment to PR #${prContext.prNumber} in ${prContext.repo}:`); - log(verbose, buildCommentBody({ source, manifestUrl, readmeUrl, summary, gitStatus })); - } - return; - } - - log(verbose, `Uploading snapshot to ${backupRepo.fullName}:${snapshotRef}`); - const uploadResult = uploadSnapshot( - backupRepo, - snapshotRef, - manifest, - uploadEntries, - ghEnv - ); - - console.log( - `[session-backup] ok: ${source.commitSha.slice(0, 12)} (${summary.fileCount} files, ${formatBytes(summary.totalBytes)})` - ); - printGitStatus(gitStatus); - log(verbose, `[session-backup] Uploaded snapshot to ${backupRepo.fullName}:${snapshotRef}`); - log(verbose, `[session-backup] Manifest: ${uploadResult.manifestUrl}`); - - if (args.postComment && prContext !== null) { - const comment = buildCommentBody({ - source, - manifestUrl: uploadResult.manifestUrl, - readmeUrl, - summary, - gitStatus, - }); - postPrComment(prContext.repo, prContext.prNumber, comment, verbose, ghEnv); - } - } finally { - fs.rmSync(tmpDir, { recursive: true, force: true }); - } -}; - -if (require.main === module) { - main(); -} - -module.exports = { - collectSessionFiles, - shouldIgnoreSessionPath, -}; diff --git a/scripts/session-backup-repo.js b/scripts/session-backup-repo.js deleted file mode 100644 index bd0845f1..00000000 --- a/scripts/session-backup-repo.js +++ /dev/null @@ -1,844 +0,0 @@ -#!/usr/bin/env bun - -const fs = require("node:fs"); -const os = require("node:os"); -const path = require("node:path"); -const { spawnSync } = require("node:child_process"); - -const BACKUP_REPO_NAME = "docker-git-sessions"; -const BACKUP_DEFAULT_BRANCH = "main"; -const GH_MAX_BUFFER_BYTES = 32 * 1024 * 1024; -// Keep each stored object below GitHub's 100 MB limit while transport batches stay smaller. -const MAX_REPO_FILE_SIZE = 99 * 1000 * 1000; -const MAX_PUSH_BATCH_BYTES = 50 * 1000 * 1000; -const GH_GIT_CREDENTIAL_HELPER = "!gh auth git-credential"; -const CHUNK_MANIFEST_SUFFIX = ".chunks.json"; -const DOCKER_GIT_CONFIG_FILE = "docker-git.json"; -const GITHUB_ENV_KEYS = ["GITHUB_TOKEN", "GH_TOKEN"]; -const PROJECT_WALK_IGNORE_DIR_NAMES = new Set([".git", "node_modules", ".cache", "tmp"]); - -const parseEnvText = (text) => { - const entries = []; - - for (const line of text.split(/\r?\n/)) { - const match = line.match(/^([A-Z0-9_]+)=(.*)$/); - if (!match) { - continue; - } - entries.push({ key: match[1], value: match[2] }); - } - - return entries; -}; - -const findGithubTokenInEnvText = (text) => { - const entries = parseEnvText(text); - - for (const key of GITHUB_ENV_KEYS) { - const entry = entries.find((item) => item.key === key); - const token = entry?.value?.trim() ?? ""; - if (token.length > 0) { - return { key, token }; - } - } - - return null; -}; - -const getDockerGitProjectsRoot = () => { - const configured = process.env.DOCKER_GIT_PROJECTS_ROOT?.trim(); - if (configured && configured.length > 0) { - return configured; - } - return path.join(os.homedir(), ".docker-git"); -}; - -const readJsonFile = (filePath) => { - try { - return JSON.parse(fs.readFileSync(filePath, "utf8")); - } catch { - return null; - } -}; - -const findDockerGitProjectForTarget = (projectsRoot, targetDir, log) => { - if (!fs.existsSync(projectsRoot)) { - return null; - } - - const stack = [projectsRoot]; - - while (stack.length > 0) { - const currentDir = stack.pop(); - const configPath = path.join(currentDir, DOCKER_GIT_CONFIG_FILE); - if (fs.existsSync(configPath)) { - const config = readJsonFile(configPath); - const candidateTarget = config?.template?.targetDir; - if (typeof candidateTarget === "string" && candidateTarget === targetDir) { - log(`Resolved docker-git project config: ${configPath}`); - return { configPath, config }; - } - } - - let entries = []; - try { - entries = fs.readdirSync(currentDir, { withFileTypes: true }); - } catch { - continue; - } - - for (const entry of entries) { - if (!entry.isDirectory()) { - continue; - } - if (PROJECT_WALK_IGNORE_DIR_NAMES.has(entry.name)) { - continue; - } - stack.push(path.join(currentDir, entry.name)); - } - } - - return null; -}; - -const getGithubEnvFileCandidates = (repoRoot, log) => { - const projectsRoot = getDockerGitProjectsRoot(); - const candidates = []; - const seen = new Set(); - - const project = findDockerGitProjectForTarget(projectsRoot, repoRoot, log); - const projectEnvGlobal = project?.config?.template?.envGlobalPath; - if (project?.configPath && typeof projectEnvGlobal === "string" && projectEnvGlobal.length > 0) { - const projectEnvPath = path.resolve(path.dirname(project.configPath), projectEnvGlobal); - candidates.push(projectEnvPath); - seen.add(projectEnvPath); - } - - const defaults = [ - path.join(projectsRoot, ".orch", "env", "global.env"), - path.join(projectsRoot, "secrets", "global.env"), - ]; - - for (const candidate of defaults) { - if (!seen.has(candidate)) { - candidates.push(candidate); - seen.add(candidate); - } - } - - return candidates; -}; - -const resolveGhEnvironment = (repoRoot, log) => { - const env = { ...process.env }; - const candidates = getGithubEnvFileCandidates(repoRoot, log); - - for (const envPath of candidates) { - if (!fs.existsSync(envPath)) { - continue; - } - const resolved = findGithubTokenInEnvText(fs.readFileSync(envPath, "utf8")); - if (resolved !== null) { - log(`Using ${resolved.key} from ${envPath} for GitHub CLI auth`); - env.GH_TOKEN = resolved.token; - env.GITHUB_TOKEN = resolved.token; - return env; - } - } - - const fromProcess = GITHUB_ENV_KEYS.find((key) => { - const value = process.env[key]?.trim() ?? ""; - return value.length > 0; - }); - - if (fromProcess) { - log(`Using ${fromProcess} from current process environment for GitHub CLI auth`); - } else { - log("No GitHub token found in docker-git env files or current process"); - } - - return env; -}; - -const ghCommand = (args, ghEnv, inputFilePath = null) => { - const resolvedArgs = inputFilePath ? [...args, "--input", inputFilePath] : args; - const result = spawnSync("gh", resolvedArgs, { - encoding: "utf8", - stdio: ["pipe", "pipe", "pipe"], - maxBuffer: GH_MAX_BUFFER_BYTES, - env: ghEnv, - }); - - return { - success: result.status === 0, - status: result.status ?? 1, - stdout: (result.stdout || "").trim(), - stderr: (result.stderr || "").trim(), - }; -}; - -const ghApi = (endpoint, ghEnv, options = {}) => { - const args = ["api", endpoint]; - if (options.method && options.method !== "GET") { - args.push("-X", options.method); - } - if (options.jq) { - args.push("--jq", options.jq); - } - if (options.rawFields) { - for (const [key, value] of Object.entries(options.rawFields)) { - args.push("-f", `${key}=${value}`); - } - } - - let inputFilePath = null; - if (options.body !== undefined) { - inputFilePath = path.join(os.tmpdir(), `docker-git-gh-api-${Date.now()}-${Math.random().toString(16).slice(2)}.json`); - fs.writeFileSync(inputFilePath, JSON.stringify(options.body), "utf8"); - } - - try { - return ghCommand(args, ghEnv, inputFilePath); - } finally { - if (inputFilePath !== null) { - fs.rmSync(inputFilePath, { force: true }); - } - } -}; - -const ghApiJson = (endpoint, ghEnv, options = {}) => { - const result = ghApi(endpoint, ghEnv, options); - if (!result.success) { - return { ...result, json: null }; - } - - try { - return { ...result, json: JSON.parse(result.stdout) }; - } catch { - return { ...result, json: null }; - } -}; - -const ensureSuccess = (result, context) => { - if (!result.success) { - throw new Error(`${context}: ${result.stderr || result.stdout || `exit ${result.status}`}`); - } - return result; -}; - -const resolveViewerLogin = (ghEnv) => - ensureSuccess( - ghApi("/user", ghEnv, { jq: ".login" }), - "failed to resolve authenticated GitHub login" - ).stdout; - -const buildBlobUrl = (repoFullName, branch, repoPath) => - `https://github.com/${repoFullName}/blob/${encodeURIComponent(branch)}/${ - repoPath.split("/").map((segment) => encodeURIComponent(segment)).join("/") - }`; - -const toSnapshotStamp = (createdAt) => - createdAt.replaceAll(":", "-").replaceAll(".", "-"); - -const getRepoInfo = (repoFullName, ghEnv) => - ghApiJson(`/repos/${repoFullName}`, ghEnv); - -const ensureBackupRepo = (ghEnv, log, createIfMissing = true) => { - const login = resolveViewerLogin(ghEnv); - const repoFullName = `${login}/${BACKUP_REPO_NAME}`; - let repoResult = getRepoInfo(repoFullName, ghEnv); - - if (!repoResult.success && createIfMissing) { - log(`Creating private session backup repository for ${login}...`); - repoResult = ghApiJson("/user/repos", ghEnv, { - method: "POST", - body: { - name: BACKUP_REPO_NAME, - private: true, - auto_init: true, - description: "docker-git session backups", - }, - }); - } - - if (!repoResult.success || repoResult.json === null) { - return null; - } - - const defaultBranch = repoResult.json.default_branch || BACKUP_DEFAULT_BRANCH; - return { - owner: login, - repo: BACKUP_REPO_NAME, - fullName: repoFullName, - defaultBranch, - htmlUrl: repoResult.json.html_url, - }; -}; - -const getBranchHeadSha = (repoFullName, branch, ghEnv) => - ensureSuccess( - ghApi(`/repos/${repoFullName}/git/ref/heads/${branch}`, ghEnv, { jq: ".object.sha" }), - `failed to resolve ${repoFullName}@${branch} ref` - ).stdout; - -const getCommitTreeSha = (repoFullName, commitSha, ghEnv) => - ensureSuccess( - ghApi(`/repos/${repoFullName}/git/commits/${commitSha}`, ghEnv, { jq: ".tree.sha" }), - `failed to resolve tree for commit ${commitSha}` - ).stdout; - -const getTreeEntries = (repoFullName, branch, ghEnv) => { - const headSha = getBranchHeadSha(repoFullName, branch, ghEnv); - const treeSha = getCommitTreeSha(repoFullName, headSha, ghEnv); - const result = ensureSuccess( - ghApiJson(`/repos/${repoFullName}/git/trees/${treeSha}?recursive=1`, ghEnv), - `failed to list tree for ${repoFullName}@${branch}` - ); - return { - headSha, - treeSha, - entries: Array.isArray(result.json?.tree) ? result.json.tree : [], - }; -}; - -const getTreeEntriesForCommit = (repoFullName, commitSha, ghEnv) => { - const treeSha = getCommitTreeSha(repoFullName, commitSha, ghEnv); - const result = ensureSuccess( - ghApiJson(`/repos/${repoFullName}/git/trees/${treeSha}?recursive=1`, ghEnv), - `failed to list tree for commit ${commitSha} in ${repoFullName}` - ); - return { - treeSha, - entries: Array.isArray(result.json?.tree) ? result.json.tree : [], - }; -}; - -const getFileContent = (repoFullName, repoPath, ghEnv, ref = BACKUP_DEFAULT_BRANCH) => { - const result = ensureSuccess( - ghApiJson(`/repos/${repoFullName}/contents/${repoPath}?ref=${encodeURIComponent(ref)}`, ghEnv), - `failed to fetch ${repoFullName}:${repoPath}` - ); - const encoding = result.json?.encoding; - const content = typeof result.json?.content === "string" ? result.json.content.replace(/\n/g, "") : ""; - if (encoding !== "base64" || content.length === 0) { - throw new Error(`unexpected content payload for ${repoFullName}:${repoPath}`); - } - return Buffer.from(content, "base64"); -}; - -const buildSnapshotRef = (sourceRepo, prNumber, commitSha, createdAt) => - `${sourceRepo}/pr-${prNumber === null ? "no-pr" : prNumber}/commit-${commitSha}/${toSnapshotStamp(createdAt)}`; - -const buildCommitMessage = ({ sourceRepo, repo, branch, commitSha, createdAt }) => - `session-backup: ${sourceRepo ?? repo ?? "unknown"} ${branch} ${commitSha.slice(0, 12)} ${toSnapshotStamp(createdAt)}`; - -const buildBatchCommitMessage = (source, batchIndex, batchCount) => - `${buildCommitMessage(source)} [files ${batchIndex}/${batchCount}]`; - -const buildManifestCommitMessage = (source) => - `${buildCommitMessage(source)} [manifest]`; - -const buildChunkManifest = (logicalName, originalSize, partNames) => ({ - original: logicalName, - originalSize, - parts: partNames, - splitAt: MAX_REPO_FILE_SIZE, - partsCount: partNames.length, - createdAt: new Date().toISOString(), -}); - -const splitLargeFile = (sourcePath, logicalName, outputDir) => { - const totalSize = fs.statSync(sourcePath).size; - const partNames = []; - const fd = fs.openSync(sourcePath, "r"); - const buffer = Buffer.alloc(1024 * 1024); - let offset = 0; - let remaining = totalSize; - let partIndex = 1; - let partBytesWritten = 0; - let partName = `${logicalName}.part${partIndex}`; - let partPath = path.join(outputDir, partName); - fs.mkdirSync(path.dirname(partPath), { recursive: true }); - let partFd = fs.openSync(partPath, "w"); - partNames.push(partName); - - try { - while (remaining > 0) { - const bytesRead = fs.readSync(fd, buffer, 0, buffer.length, offset); - if (bytesRead === 0) { - break; - } - - let chunkOffset = 0; - while (chunkOffset < bytesRead) { - if (partBytesWritten >= MAX_REPO_FILE_SIZE) { - fs.closeSync(partFd); - partIndex += 1; - partBytesWritten = 0; - partName = `${logicalName}.part${partIndex}`; - partPath = path.join(outputDir, partName); - fs.mkdirSync(path.dirname(partPath), { recursive: true }); - partFd = fs.openSync(partPath, "w"); - partNames.push(partName); - } - - const remainingChunk = bytesRead - chunkOffset; - const remainingPart = MAX_REPO_FILE_SIZE - partBytesWritten; - const toWrite = Math.min(remainingChunk, remainingPart); - fs.writeSync(partFd, buffer.subarray(chunkOffset, chunkOffset + toWrite)); - partBytesWritten += toWrite; - chunkOffset += toWrite; - } - - offset += bytesRead; - remaining -= bytesRead; - } - } finally { - fs.closeSync(fd); - fs.closeSync(partFd); - } - - return { - originalSize: totalSize, - partNames, - manifestName: `${logicalName}${CHUNK_MANIFEST_SUFFIX}`, - }; -}; - -const prepareUploadArtifacts = (sessionFiles, snapshotRef, repoFullName, branch, tmpDir, log) => { - const uploadEntries = []; - const manifestFiles = []; - - for (const file of sessionFiles) { - if (file.size <= MAX_REPO_FILE_SIZE) { - const repoPath = `${snapshotRef}/${file.logicalName}`; - uploadEntries.push({ - repoPath, - sourcePath: file.sourcePath, - type: "file", - size: file.size, - }); - manifestFiles.push({ - type: "file", - name: file.logicalName, - size: file.size, - repoPath, - url: buildBlobUrl(repoFullName, branch, repoPath), - }); - continue; - } - - log(`Splitting oversized file ${file.logicalName} (${file.size} bytes)`); - const split = splitLargeFile(file.sourcePath, file.logicalName, tmpDir); - const chunkManifest = buildChunkManifest(file.logicalName, split.originalSize, split.partNames); - const chunkManifestPath = path.join(tmpDir, split.manifestName); - fs.mkdirSync(path.dirname(chunkManifestPath), { recursive: true }); - fs.writeFileSync(chunkManifestPath, `${JSON.stringify(chunkManifest, null, 2)}\n`, "utf8"); - - const partEntries = split.partNames.map((partName) => { - const repoPath = `${snapshotRef}/${partName}`; - uploadEntries.push({ - repoPath, - sourcePath: path.join(tmpDir, partName), - type: "chunk-part", - size: fs.statSync(path.join(tmpDir, partName)).size, - }); - return { - name: partName, - repoPath, - url: buildBlobUrl(repoFullName, branch, repoPath), - }; - }); - - const chunkManifestRepoPath = `${snapshotRef}/${split.manifestName}`; - uploadEntries.push({ - repoPath: chunkManifestRepoPath, - sourcePath: chunkManifestPath, - type: "chunk-manifest", - size: fs.statSync(chunkManifestPath).size, - }); - - manifestFiles.push({ - type: "chunked", - name: file.logicalName, - originalSize: split.originalSize, - chunkManifestPath: chunkManifestRepoPath, - chunkManifestUrl: buildBlobUrl(repoFullName, branch, chunkManifestRepoPath), - parts: partEntries, - }); - } - - return { uploadEntries, manifestFiles }; -}; - -const splitUploadEntriesIntoBatches = (uploadEntries) => { - const batches = []; - let currentBatch = []; - let currentBatchBytes = 0; - - for (const entry of uploadEntries) { - if (currentBatch.length > 0 && currentBatchBytes + entry.size > MAX_PUSH_BATCH_BYTES) { - batches.push(currentBatch); - currentBatch = []; - currentBatchBytes = 0; - } - - currentBatch.push(entry); - currentBatchBytes += entry.size; - } - - if (currentBatch.length > 0) { - batches.push(currentBatch); - } - - return batches; -}; - -const runGitCommand = (repoDir, args, env) => { - const result = spawnSync("git", ["-c", "core.hooksPath=/dev/null", "-c", "protocol.version=2", "-C", repoDir, ...args], { - encoding: "utf8", - stdio: ["pipe", "pipe", "pipe"], - env, - }); - - return { - success: result.status === 0, - status: result.status ?? 1, - stdout: (result.stdout || "").trim(), - stderr: (result.stderr || "").trim(), - }; -}; - -const ensureGitSuccess = (result, context) => { - if (!result.success) { - throw new Error(`${context}: ${result.stderr || result.stdout || `exit ${result.status}`}`); - } - return result; -}; - -const runGitCommandWithInput = (repoDir, args, env, input) => { - const result = spawnSync("git", ["-c", "core.hooksPath=/dev/null", "-c", "protocol.version=2", "-C", repoDir, ...args], { - encoding: "utf8", - stdio: ["pipe", "pipe", "pipe"], - env, - input, - }); - - return { - success: result.status === 0, - status: result.status ?? 1, - stdout: (result.stdout || "").trim(), - stderr: (result.stderr || "").trim(), - }; -}; - -const buildGitPushEnv = (ghEnv, token) => ({ - ...ghEnv, - GH_TOKEN: token, - GITHUB_TOKEN: token, - GIT_AUTH_TOKEN: token, - GIT_TERMINAL_PROMPT: "0", -}); - -const initializeUploadRepo = (repoDir, backupRepo, gitEnv) => { - ensureGitSuccess(runGitCommand(repoDir, ["init", "-q"], gitEnv), `failed to init git repo ${repoDir}`); - ensureGitSuccess( - runGitCommand(repoDir, ["remote", "add", "origin", `https://github.com/${backupRepo.fullName}.git`], gitEnv), - `failed to configure git remote for ${backupRepo.fullName}` - ); -}; - -const fetchRemoteBranchTip = (repoDir, branch, gitEnv) => { - ensureGitSuccess( - runGitCommand( - repoDir, - [ - "-c", - `credential.helper=${GH_GIT_CREDENTIAL_HELPER}`, - "fetch", - "--quiet", - "--no-tags", - "--depth=1", - "--filter=blob:none", - "origin", - `refs/heads/${branch}:refs/remotes/origin/${branch}`, - ], - gitEnv - ), - `failed to fetch ${branch} tip from backup repository` - ); - return ensureGitSuccess( - runGitCommand(repoDir, ["rev-parse", `refs/remotes/origin/${branch}`], gitEnv), - `failed to resolve fetched ${branch} tip` - ).stdout; -}; - -const hashFileObject = (repoDir, sourcePath, gitEnv) => - ensureGitSuccess( - runGitCommand(repoDir, ["hash-object", "-w", sourcePath], gitEnv), - `failed to hash ${sourcePath}` - ).stdout; - -const createTreeObject = (repoDir, entries, gitEnv) => { - const body = entries - .slice() - .sort((left, right) => left.name.localeCompare(right.name)) - .map((entry) => `${entry.mode} ${entry.type} ${entry.sha}\t${entry.name}`) - .join("\n"); - return ensureGitSuccess( - runGitCommandWithInput(repoDir, ["mktree", "--missing"], gitEnv, body.length > 0 ? `${body}\n` : ""), - "failed to create git tree" - ).stdout; -}; - -const createCommitObject = (repoDir, treeSha, parentSha, message, createdAt, owner, gitEnv) => { - const authorEmail = `${owner}@users.noreply.github.com`; - const unixSeconds = Math.floor(new Date(createdAt).getTime() / 1000); - const commitBody = [ - `tree ${treeSha}`, - `parent ${parentSha}`, - `author ${owner} <${authorEmail}> ${unixSeconds} +0000`, - `committer ${owner} <${authorEmail}> ${unixSeconds} +0000`, - "", - message, - "", - ].join("\n"); - return ensureGitSuccess( - runGitCommandWithInput(repoDir, ["hash-object", "-t", "commit", "-w", "--stdin"], gitEnv, commitBody), - "failed to create git commit" - ).stdout; -}; - -const updateLocalRef = (repoDir, refName, commitSha, gitEnv) => - ensureGitSuccess( - runGitCommand(repoDir, ["update-ref", refName, commitSha], gitEnv), - `failed to update local ref ${refName}` - ); - -const isNonFastForwardPushError = (result) => - /non-fast-forward|fetch first|rejected/i.test(`${result.stderr}\n${result.stdout}`); - -const pushCommitToBranch = (repoDir, sourceRef, branch, gitEnv) => - runGitCommand( - repoDir, - [ - "-c", - `credential.helper=${GH_GIT_CREDENTIAL_HELPER}`, - "push", - "origin", - `${sourceRef}:refs/heads/${branch}`, - ], - gitEnv - ); - -const buildFileMapFromTreeEntries = (entries) => { - const fileMap = new Map(); - for (const entry of entries) { - if (entry.type === "tree") { - continue; - } - if (typeof entry.path !== "string" || typeof entry.sha !== "string" || typeof entry.mode !== "string") { - continue; - } - fileMap.set(entry.path, { - mode: entry.mode, - type: entry.type, - sha: entry.sha, - }); - } - return fileMap; -}; - -const buildDirectoryGraph = (fileMap) => { - const directories = new Set([""]); - const childrenByDir = new Map(); - - const addChild = (dirPath, child) => { - const current = childrenByDir.get(dirPath) ?? []; - current.push(child); - childrenByDir.set(dirPath, current); - }; - - for (const [repoPath, entry] of fileMap.entries()) { - const segments = repoPath.split("/"); - const name = segments.pop(); - const dirPath = segments.join("/"); - if (!name) { - continue; - } - directories.add(dirPath); - for (let index = 1; index <= segments.length; index += 1) { - directories.add(segments.slice(0, index).join("/")); - } - addChild(dirPath, { - name, - mode: entry.mode, - type: entry.type, - sha: entry.sha, - }); - } - - return { - directories: Array.from(directories).sort((left, right) => { - const depthDiff = right.split("/").length - left.split("/").length; - return depthDiff !== 0 ? depthDiff : right.localeCompare(left); - }), - childrenByDir, - addChild, - }; -}; - -const writeMergedTree = (repoDir, existingEntries, newEntries, gitEnv) => { - const fileMap = buildFileMapFromTreeEntries(existingEntries); - for (const entry of newEntries) { - fileMap.set(entry.repoPath, { - mode: "100644", - type: "blob", - sha: entry.sha, - }); - } - - const { directories, childrenByDir, addChild } = buildDirectoryGraph(fileMap); - - for (const dirPath of directories) { - if (dirPath.length === 0) { - continue; - } - const childEntries = childrenByDir.get(dirPath) ?? []; - const treeSha = createTreeObject(repoDir, childEntries, gitEnv); - const segments = dirPath.split("/"); - const name = segments.pop(); - const parentDir = segments.join("/"); - if (!name) { - continue; - } - addChild(parentDir, { - name, - mode: "040000", - type: "tree", - sha: treeSha, - }); - } - - return createTreeObject(repoDir, childrenByDir.get("") ?? [], gitEnv); -}; - -const buildUploadCommitMessage = (source, batchIndex, batchCount) => - batchCount <= 1 - ? buildCommitMessage(source) - : `${buildCommitMessage(source)} [batch ${batchIndex}/${batchCount}]`; - -const uploadSnapshot = (backupRepo, snapshotRef, snapshotManifest, uploadEntries, ghEnv) => { - const token = ghEnv.GITHUB_TOKEN?.trim() || ghEnv.GH_TOKEN?.trim() || ""; - if (token.length === 0) { - throw new Error("GitHub token missing for backup repository push"); - } - - const uploadRoot = fs.mkdtempSync(path.join(os.tmpdir(), "session-backup-git-push-")); - const manifestPath = `${snapshotRef}/manifest.json`; - const manifestTempPath = path.join(uploadRoot, "manifest.json"); - fs.writeFileSync(manifestTempPath, `${JSON.stringify(snapshotManifest, null, 2)}\n`, "utf8"); - const manifestEntry = { - repoPath: manifestPath, - sourcePath: manifestTempPath, - size: fs.statSync(manifestTempPath).size, - }; - const uploadBatches = splitUploadEntriesIntoBatches([...uploadEntries, manifestEntry]); - - try { - for (let attempt = 1; attempt <= 3; attempt += 1) { - const attemptDir = path.join(uploadRoot, `attempt-${attempt}`); - const repoDir = path.join(attemptDir, "repo"); - fs.mkdirSync(repoDir, { recursive: true }); - const gitEnv = buildGitPushEnv(ghEnv, token); - - initializeUploadRepo(repoDir, backupRepo, gitEnv); - let headSha = fetchRemoteBranchTip(repoDir, backupRepo.defaultBranch, gitEnv); - let { entries: existingEntries } = getTreeEntriesForCommit(backupRepo.fullName, headSha, ghEnv); - let lastCommitSha = headSha; - let shouldRetry = false; - - for (let batchIndex = 0; batchIndex < uploadBatches.length; batchIndex += 1) { - const hashedEntries = uploadBatches[batchIndex].map((entry) => ({ - repoPath: entry.repoPath, - sha: hashFileObject(repoDir, entry.sourcePath, gitEnv), - })); - - const nextTreeSha = writeMergedTree(repoDir, existingEntries, hashedEntries, gitEnv); - const commitSha = createCommitObject( - repoDir, - nextTreeSha, - headSha, - buildUploadCommitMessage(snapshotManifest.source, batchIndex + 1, uploadBatches.length), - snapshotManifest.source.createdAt, - backupRepo.owner, - gitEnv - ); - const localRef = `refs/heads/session-backup-upload-${attempt}-${batchIndex + 1}`; - updateLocalRef(repoDir, localRef, commitSha, gitEnv); - const pushResult = pushCommitToBranch(repoDir, localRef, backupRepo.defaultBranch, gitEnv); - if (!pushResult.success) { - if (attempt < 3 && isNonFastForwardPushError(pushResult)) { - shouldRetry = true; - break; - } - throw new Error(`failed to push backup commit: ${pushResult.stderr || pushResult.stdout || `exit ${pushResult.status}`}`); - } - - headSha = commitSha; - lastCommitSha = commitSha; - existingEntries = existingEntries.concat( - hashedEntries.map((entry) => ({ - path: entry.repoPath, - mode: "100644", - type: "blob", - sha: entry.sha, - })) - ); - } - - if (shouldRetry) { - continue; - } - - return { - commitSha: lastCommitSha, - manifestPath, - manifestUrl: buildBlobUrl(backupRepo.fullName, backupRepo.defaultBranch, manifestPath), - }; - } - - throw new Error("failed to push backup commit after 3 attempts"); - } finally { - fs.rmSync(uploadRoot, { recursive: true, force: true }); - } -}; - -const sanitizeSnapshotRefForOutput = (snapshotRef) => - snapshotRef.replace(/[\\/]/g, "_"); - -const decodeChunkManifestBuffer = (buffer, sourcePath) => { - try { - return JSON.parse(buffer.toString("utf8")); - } catch (error) { - throw new Error(`failed to parse chunk manifest ${sourcePath}: ${error.message}`); - } -}; - -module.exports = { - BACKUP_DEFAULT_BRANCH, - BACKUP_REPO_NAME, - CHUNK_MANIFEST_SUFFIX, - MAX_REPO_FILE_SIZE, - buildBlobUrl, - buildSnapshotRef, - decodeChunkManifestBuffer, - ensureBackupRepo, - getFileContent, - getTreeEntries, - parseEnvText, - prepareUploadArtifacts, - resolveGhEnvironment, - sanitizeSnapshotRefForOutput, - uploadSnapshot, -}; diff --git a/scripts/session-list-gists.js b/scripts/session-list-gists.js deleted file mode 100644 index 87e637e9..00000000 --- a/scripts/session-list-gists.js +++ /dev/null @@ -1,230 +0,0 @@ -#!/usr/bin/env bun - -/** - * List AI Session Backups from the private session backup repository - * - * Usage: - * bun scripts/session-list-gists.js [command] [options] - * - * Commands: - * list List session snapshots (default) - * view View metadata for a snapshot - * download Download snapshot contents to local directory - * - * Options: - * --limit Maximum number of snapshots to list (default: 20) - * --repo Filter by source repository - * --output Output directory for download (default: ./.session-restore) - * --verbose Enable verbose logging - */ - -const fs = require("node:fs"); -const path = require("node:path"); - -const { - ensureBackupRepo, - getFileContent, - getTreeEntries, - resolveGhEnvironment, - sanitizeSnapshotRefForOutput, -} = require("./session-backup-repo.js"); - -const parseArgs = () => { - const args = process.argv.slice(2); - const result = { - command: "list", - snapshotRef: null, - limit: 20, - repo: null, - output: "./.session-restore", - verbose: false, - }; - - let i = 0; - while (i < args.length) { - const arg = args[i]; - - if (arg.startsWith("--")) { - switch (arg) { - case "--limit": - result.limit = parseInt(args[++i], 10); - break; - case "--repo": - result.repo = args[++i]; - break; - case "--output": - result.output = args[++i]; - break; - case "--verbose": - result.verbose = true; - break; - case "--help": - console.log(`Usage: session-list-gists.js [command] [options] - -Commands: - list List session snapshots (default) - view View metadata for a snapshot - download Download snapshot contents to local directory - -Options: - --limit Maximum number of snapshots to list (default: 20) - --repo Filter by source repository - --output Output directory for download (default: ./.session-restore) - --verbose Enable verbose logging - --help Show this help message`); - process.exit(0); - } - } else if (!result.command || result.command === "list") { - if (arg === "list" || arg === "view" || arg === "download") { - result.command = arg; - } else if (result.command !== "list") { - result.snapshotRef = arg; - } - } else if (!result.snapshotRef) { - result.snapshotRef = arg; - } - i++; - } - - return result; -}; - -const log = (verbose, message) => { - if (verbose) { - console.log(`[session-backups] ${message}`); - } -}; - -const ensureBackupRepoOrExit = (ghEnv, verbose) => { - const backupRepo = ensureBackupRepo(ghEnv, (message) => log(verbose, message), false); - if (backupRepo === null) { - console.log("No private session backup repository found."); - process.exit(0); - } - return backupRepo; -}; - -const decodeJsonBuffer = (buffer, context) => { - try { - return JSON.parse(buffer.toString("utf8")); - } catch (error) { - console.error(`Failed to parse JSON for ${context}: ${error.message}`); - process.exit(1); - } -}; - -const getManifestRepoPath = (snapshotRef) => `${snapshotRef}/manifest.json`; - -const fetchManifest = (backupRepo, snapshotRef, ghEnv) => { - const manifestPath = getManifestRepoPath(snapshotRef); - const buffer = getFileContent(backupRepo.fullName, manifestPath, ghEnv, backupRepo.defaultBranch); - return { - path: manifestPath, - data: decodeJsonBuffer(buffer, manifestPath), - }; -}; - -const listSnapshots = (limit, repoFilter, backupRepo, ghEnv, verbose) => { - log(verbose, `Listing snapshots from ${backupRepo.fullName}`); - const { entries } = getTreeEntries(backupRepo.fullName, backupRepo.defaultBranch, ghEnv); - const manifestPaths = entries - .filter((entry) => entry.type === "blob" && typeof entry.path === "string" && entry.path.endsWith("/manifest.json")) - .map((entry) => entry.path); - - const filtered = repoFilter - ? manifestPaths.filter((entryPath) => entryPath.startsWith(`${repoFilter}/`)) - : manifestPaths; - - if (filtered.length === 0) { - console.log("No session snapshots found."); - if (repoFilter) { - console.log(`(Filtered by repo: ${repoFilter})`); - } - return; - } - - const selected = filtered.slice(0, limit); - console.log("Session Snapshots:\n"); - for (const manifestPath of selected) { - const snapshotRef = manifestPath.slice(0, -"/manifest.json".length); - const manifest = fetchManifest(backupRepo, snapshotRef, ghEnv); - console.log(snapshotRef); - console.log(` Source: ${manifest.data.source.repo}`); - console.log(` Commit: ${manifest.data.source.commitSha}`); - console.log(` Created: ${manifest.data.createdAt}`); - console.log(` Manifest: https://github.com/${backupRepo.fullName}/blob/${encodeURIComponent(backupRepo.defaultBranch)}/${manifest.path.split("/").map((segment) => encodeURIComponent(segment)).join("/")}`); - console.log(""); - } - - console.log(`Total: ${filtered.length} snapshot(s)`); -}; - -const viewSnapshot = (snapshotRef, backupRepo, ghEnv, verbose) => { - if (!snapshotRef) { - console.error("Error: snapshot-ref is required for view command"); - process.exit(1); - } - - log(verbose, `Viewing snapshot: ${snapshotRef}`); - const manifest = fetchManifest(backupRepo, snapshotRef, ghEnv); - console.log(JSON.stringify(manifest.data, null, 2)); -}; - -const downloadSnapshot = (snapshotRef, outputDir, backupRepo, ghEnv, verbose) => { - if (!snapshotRef) { - console.error("Error: snapshot-ref is required for download command"); - process.exit(1); - } - - log(verbose, `Downloading snapshot ${snapshotRef} to ${outputDir}`); - const manifest = fetchManifest(backupRepo, snapshotRef, ghEnv); - const outputPath = path.resolve(outputDir, sanitizeSnapshotRefForOutput(snapshotRef)); - fs.mkdirSync(outputPath, { recursive: true }); - fs.writeFileSync(path.join(outputPath, "manifest.json"), `${JSON.stringify(manifest.data, null, 2)}\n`, "utf8"); - - for (const file of manifest.data.files) { - const targetPath = path.join(outputPath, file.name); - fs.mkdirSync(path.dirname(targetPath), { recursive: true }); - if (file.type === "chunked") { - const buffers = file.parts.map((part) => - getFileContent(backupRepo.fullName, part.repoPath, ghEnv, backupRepo.defaultBranch) - ); - fs.writeFileSync(targetPath, Buffer.concat(buffers)); - continue; - } - - const buffer = getFileContent(backupRepo.fullName, file.repoPath, ghEnv, backupRepo.defaultBranch); - fs.writeFileSync(targetPath, buffer); - } - - console.log(`Downloaded snapshot to: ${outputPath}`); - console.log("\nTo restore session files, copy them to the appropriate location:"); - console.log(" - .codex/... -> ~/.codex/"); - console.log(" - .claude/... -> ~/.claude/"); - console.log(" - .qwen/... -> ~/.qwen/"); - console.log(" - .gemini/... -> ~/.gemini/"); -}; - -const main = () => { - const args = parseArgs(); - const verbose = args.verbose; - const ghEnv = resolveGhEnvironment(process.cwd(), (message) => log(verbose, message)); - const backupRepo = ensureBackupRepoOrExit(ghEnv, verbose); - - switch (args.command) { - case "list": - listSnapshots(args.limit, args.repo, backupRepo, ghEnv, verbose); - break; - case "view": - viewSnapshot(args.snapshotRef, backupRepo, ghEnv, verbose); - break; - case "download": - downloadSnapshot(args.snapshotRef, args.output, backupRepo, ghEnv, verbose); - break; - default: - console.error(`Unknown command: ${args.command}`); - process.exit(1); - } -}; - -main(); From 327f32254fbafcfa35abd8172322e3079d86b23a Mon Sep 17 00:00:00 2001 From: skulidropek <66840575+skulidropek@users.noreply.github.com> Date: Sun, 26 Apr 2026 20:19:28 +0000 Subject: [PATCH 2/5] fix: satisfy effect lint for session sync resolution --- packages/app/src/lib/shell/files.ts | 58 ++++++++++++++++++----------- packages/lib/src/shell/files.ts | 58 ++++++++++++++++++----------- 2 files changed, 72 insertions(+), 44 deletions(-) diff --git a/packages/app/src/lib/shell/files.ts b/packages/app/src/lib/shell/files.ts index 68b90748..ecdf7c83 100644 --- a/packages/app/src/lib/shell/files.ts +++ b/packages/app/src/lib/shell/files.ts @@ -2,8 +2,6 @@ import type { PlatformError } from "@effect/platform/Error" import type * as FileSystem from "@effect/platform/FileSystem" import type * as Path from "@effect/platform/Path" -import { createRequire } from "node:module" -import nodePath from "node:path" import { Effect, Match } from "effect" import { dockerGitScriptNames } from "../core/docker-git-scripts.js" @@ -17,8 +15,8 @@ import { resolveWorkspaceRoot } from "./workspace-root.js" const ensureParentDir = (path: Path.Path, fs: FileSystem.FileSystem, filePath: string) => fs.makeDirectory(path.dirname(filePath), { recursive: true }) -const require = createRequire(import.meta.url) const sessionSyncToolRelativePath = ".docker-git-tools/docker-git-session-sync" +const sessionSyncPackageJsonSpecifier = "@prover-coder-ai/docker-git-session-sync/package.json" const fallbackHostResources = { cpuCount: 1, @@ -145,26 +143,37 @@ const provisionDockerGitScripts = ( } }) -const resolveInstalledSessionSyncTool = (): string | null => { - try { - const packageJsonPath = require.resolve("@prover-coder-ai/docker-git-session-sync/package.json") - return nodePath.join(nodePath.dirname(packageJsonPath), "dist", "docker-git-session-sync.js") - } catch { - return null - } +const resolveFileUrlPath = (fileUrl: string): string => { + const url = new URL(fileUrl) + return url.protocol === "file:" ? decodeURIComponent(url.pathname) : fileUrl } -const sessionSyncToolCandidates = (path: Path.Path, workspaceRoot: string): ReadonlyArray => { - const installed = resolveInstalledSessionSyncTool() - const workspaceCandidate = path.join( - workspaceRoot, - "packages", - "docker-git-session-sync", - "dist", - "docker-git-session-sync.js" +const resolveInstalledSessionSyncTool = (path: Path.Path): Effect.Effect => + Effect.try(() => import.meta.resolve(sessionSyncPackageJsonSpecifier)).pipe( + Effect.match({ + onFailure: () => null, + onSuccess: (packageJsonUrl) => { + const packageJsonPath = resolveFileUrlPath(packageJsonUrl) + return path.join(path.dirname(packageJsonPath), "dist", "docker-git-session-sync.js") + } + }) ) - return installed === null ? [workspaceCandidate] : [workspaceCandidate, installed] -} + +const sessionSyncToolCandidates = ( + path: Path.Path, + workspaceRoot: string +): Effect.Effect> => + Effect.gen(function*(_) { + const installed = yield* _(resolveInstalledSessionSyncTool(path)) + const workspaceCandidate = path.join( + workspaceRoot, + "packages", + "docker-git-session-sync", + "dist", + "docker-git-session-sync.js" + ) + return installed === null ? [workspaceCandidate] : [workspaceCandidate, installed] + }) // CHANGE: provision standalone session sync tool into the Docker build context // WHY: generated containers call docker-git-session-sync directly after git push @@ -181,7 +190,8 @@ const provisionDockerGitSessionSyncTool = ( Effect.gen(function*(_) { const workspaceRoot = yield* _(resolveWorkspaceRoot(process.cwd())) const targetPath = path.join(baseDir, sessionSyncToolRelativePath) - for (const sourcePath of sessionSyncToolCandidates(path, workspaceRoot)) { + const candidates = yield* _(sessionSyncToolCandidates(path, workspaceRoot)) + for (const sourcePath of candidates) { const exists = yield* _(fs.exists(sourcePath)) if (exists) { const contents = yield* _(fs.readFileString(sourcePath)) @@ -190,7 +200,11 @@ const provisionDockerGitSessionSyncTool = ( return } } - yield* _(Effect.dieMessage("docker-git-session-sync build artifact not found; run bun run --cwd packages/docker-git-session-sync build")) + yield* _( + Effect.dieMessage( + "docker-git-session-sync build artifact not found; run bun run --cwd packages/docker-git-session-sync build" + ) + ) }) // CHANGE: write generated docker-git files to disk diff --git a/packages/lib/src/shell/files.ts b/packages/lib/src/shell/files.ts index c26ca884..82976294 100644 --- a/packages/lib/src/shell/files.ts +++ b/packages/lib/src/shell/files.ts @@ -1,8 +1,6 @@ import type { PlatformError } from "@effect/platform/Error" import type * as FileSystem from "@effect/platform/FileSystem" import type * as Path from "@effect/platform/Path" -import { createRequire } from "node:module" -import nodePath from "node:path" import { Effect, Match } from "effect" import { dockerGitScriptNames } from "../core/docker-git-scripts.js" @@ -16,8 +14,8 @@ import { resolveWorkspaceRoot } from "./workspace-root.js" const ensureParentDir = (path: Path.Path, fs: FileSystem.FileSystem, filePath: string) => fs.makeDirectory(path.dirname(filePath), { recursive: true }) -const require = createRequire(import.meta.url) const sessionSyncToolRelativePath = ".docker-git-tools/docker-git-session-sync" +const sessionSyncPackageJsonSpecifier = "@prover-coder-ai/docker-git-session-sync/package.json" const fallbackHostResources = { cpuCount: 1, @@ -144,26 +142,37 @@ const provisionDockerGitScripts = ( } }) -const resolveInstalledSessionSyncTool = (): string | null => { - try { - const packageJsonPath = require.resolve("@prover-coder-ai/docker-git-session-sync/package.json") - return nodePath.join(nodePath.dirname(packageJsonPath), "dist", "docker-git-session-sync.js") - } catch { - return null - } +const resolveFileUrlPath = (fileUrl: string): string => { + const url = new URL(fileUrl) + return url.protocol === "file:" ? decodeURIComponent(url.pathname) : fileUrl } -const sessionSyncToolCandidates = (path: Path.Path, workspaceRoot: string): ReadonlyArray => { - const installed = resolveInstalledSessionSyncTool() - const workspaceCandidate = path.join( - workspaceRoot, - "packages", - "docker-git-session-sync", - "dist", - "docker-git-session-sync.js" +const resolveInstalledSessionSyncTool = (path: Path.Path): Effect.Effect => + Effect.try(() => import.meta.resolve(sessionSyncPackageJsonSpecifier)).pipe( + Effect.match({ + onFailure: () => null, + onSuccess: (packageJsonUrl) => { + const packageJsonPath = resolveFileUrlPath(packageJsonUrl) + return path.join(path.dirname(packageJsonPath), "dist", "docker-git-session-sync.js") + } + }) ) - return installed === null ? [workspaceCandidate] : [workspaceCandidate, installed] -} + +const sessionSyncToolCandidates = ( + path: Path.Path, + workspaceRoot: string +): Effect.Effect> => + Effect.gen(function*(_) { + const installed = yield* _(resolveInstalledSessionSyncTool(path)) + const workspaceCandidate = path.join( + workspaceRoot, + "packages", + "docker-git-session-sync", + "dist", + "docker-git-session-sync.js" + ) + return installed === null ? [workspaceCandidate] : [workspaceCandidate, installed] + }) // CHANGE: provision standalone session sync tool into the Docker build context // WHY: generated containers call docker-git-session-sync directly after git push @@ -180,7 +189,8 @@ const provisionDockerGitSessionSyncTool = ( Effect.gen(function*(_) { const workspaceRoot = yield* _(resolveWorkspaceRoot(process.cwd())) const targetPath = path.join(baseDir, sessionSyncToolRelativePath) - for (const sourcePath of sessionSyncToolCandidates(path, workspaceRoot)) { + const candidates = yield* _(sessionSyncToolCandidates(path, workspaceRoot)) + for (const sourcePath of candidates) { const exists = yield* _(fs.exists(sourcePath)) if (exists) { const contents = yield* _(fs.readFileString(sourcePath)) @@ -189,7 +199,11 @@ const provisionDockerGitSessionSyncTool = ( return } } - yield* _(Effect.dieMessage("docker-git-session-sync build artifact not found; run bun run --cwd packages/docker-git-session-sync build")) + yield* _( + Effect.dieMessage( + "docker-git-session-sync build artifact not found; run bun run --cwd packages/docker-git-session-sync build" + ) + ) }) // CHANGE: write generated docker-git files to disk From 73cccad4c9979790399bbe664b31095295754b0e Mon Sep 17 00:00:00 2001 From: skulidropek <66840575+skulidropek@users.noreply.github.com> Date: Sun, 26 Apr 2026 20:57:08 +0000 Subject: [PATCH 3/5] fix: keep session backup current --- .../docker-git-session-sync/src/backup.ts | 51 ++++++----- packages/docker-git-session-sync/src/cli.ts | 2 +- packages/docker-git-session-sync/src/core.ts | 24 ++++- packages/docker-git-session-sync/src/shell.ts | 33 ++++++- .../docker-git-session-sync/src/snapshots.ts | 6 +- .../tests/session-files.test.ts | 87 ++++++++++++++++--- 6 files changed, 159 insertions(+), 44 deletions(-) diff --git a/packages/docker-git-session-sync/src/backup.ts b/packages/docker-git-session-sync/src/backup.ts index 62e8a0c7..052428ff 100644 --- a/packages/docker-git-session-sync/src/backup.ts +++ b/packages/docker-git-session-sync/src/backup.ts @@ -11,6 +11,7 @@ import { buildSnapshotRef, formatBytes, isPathWithinParent, + isChatTranscriptPath, sessionDirNames, sessionWalkIgnoreDirNames, shouldIgnoreSessionPath, @@ -202,7 +203,11 @@ const findPrContext = ( return null } -const getAllowedSessionRoots = (): ReadonlyArray<{ readonly name: string; readonly path: string }> => { +type SessionDir = { readonly name: string; readonly path: string } + +const allowedSessionRootDescription = sessionDirNames.map((dirName) => `~/${dirName}`).join(" or ") + +const getAllowedSessionRoots = (): ReadonlyArray => { const homeDir = os.homedir() return sessionDirNames .map((dirName) => ({ name: dirName, path: path.join(homeDir, dirName) })) @@ -213,14 +218,22 @@ const resolveAllowedSessionDir = ( candidatePath: string, verbose: boolean, output: Output -): string | null => { +): SessionDir | null => { const resolvedPath = path.resolve(candidatePath) if (!fs.existsSync(resolvedPath)) { return null } + const stats = fs.statSync(resolvedPath) + if (!stats.isDirectory()) { + return null + } for (const root of getAllowedSessionRoots()) { if (isPathWithinParent(resolvedPath, root.path)) { - return resolvedPath + const relativePath = toLogicalRelativePath(path.relative(root.path, resolvedPath)) + return { + name: relativePath.length === 0 ? root.name : path.posix.join(root.name, relativePath), + path: resolvedPath + } } } logVerbose(verbose, output, `Skipping non-session directory: ${candidatePath}`) @@ -231,23 +244,21 @@ const findSessionDirs = ( explicitPath: string | null, verbose: boolean, output: Output -): ReadonlyArray<{ readonly name: string; readonly path: string }> => { +): ReadonlyArray => { if (explicitPath !== null) { - const allowedPath = resolveAllowedSessionDir(path.resolve(explicitPath), verbose, output) - if (allowedPath === null) { - throw new Error( - `--session-dir must point to a directory under ${sessionDirNames.map((dirName) => `~/${dirName}`).join(", ")}` - ) + const allowedDir = resolveAllowedSessionDir(path.resolve(explicitPath), verbose, output) + if (allowedDir === null) { + throw new Error(`--session-dir must point to a directory under ${allowedSessionRootDescription}`) } - return [{ name: path.basename(allowedPath), path: allowedPath }] + return [allowedDir] } - const dirs: Array<{ readonly name: string; readonly path: string }> = [] + const dirs: Array = [] for (const root of getAllowedSessionRoots()) { - const allowedPath = resolveAllowedSessionDir(root.path, verbose, output) - if (allowedPath !== null) { - logVerbose(verbose, output, `Found session directory: ${allowedPath}`) - dirs.push({ name: root.name, path: allowedPath }) + const allowedDir = resolveAllowedSessionDir(root.path, verbose, output) + if (allowedDir !== null) { + logVerbose(verbose, output, `Found session directory: ${allowedDir.path}`) + dirs.push(allowedDir) } } return dirs @@ -277,6 +288,10 @@ export const collectSessionFiles = (dirPath: string, baseName: string, verbose: try { const stats = fs.statSync(fullPath) const logicalName = path.posix.join(baseName, logicalRelPath) + if (!isChatTranscriptPath(logicalName)) { + logVerbose(verbose, output, `Skipping non-chat file: ${logicalName}`) + continue + } files.push({ logicalName, sourcePath: fullPath, size: stats.size }) logVerbose(verbose, output, `Collected file: ${logicalName} (${stats.size} bytes)`) } catch (error) { @@ -364,10 +379,6 @@ export const backupSessions = (options: BackupOptions, cwd: string, output: Outp } const sessionFiles = sessionDirs.flatMap((dir) => collectSessionFiles(dir.path, dir.name, verbose, output)) - if (sessionFiles.length === 0) { - logVerbose(verbose, output, "No session files found to backup") - return 0 - } logVerbose(verbose, output, `Total files to backup: ${sessionFiles.length}`) const backupRepo = ensureBackupRepo(ghEnv, (message) => logVerbose(verbose, output, message), !options.dryRun) @@ -379,7 +390,7 @@ export const backupSessions = (options: BackupOptions, cwd: string, output: Outp const tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), "session-sync-repo-")) try { const snapshotCreatedAt = new Date().toISOString() - const snapshotRef = buildSnapshotRef(sourceRepo, prContext?.prNumber ?? null, commitSha, snapshotCreatedAt) + const snapshotRef = buildSnapshotRef(sourceRepo, prContext?.prNumber ?? null, branch) const prepared = prepareUploadArtifacts( sessionFiles, snapshotRef, diff --git a/packages/docker-git-session-sync/src/cli.ts b/packages/docker-git-session-sync/src/cli.ts index 3f40ba01..0bc94a0b 100644 --- a/packages/docker-git-session-sync/src/cli.ts +++ b/packages/docker-git-session-sync/src/cli.ts @@ -11,7 +11,7 @@ const usageText = `Usage: docker-git-session-sync download [options] Options: - --session-dir Path under ~/.codex, ~/.claude, ~/.qwen, or ~/.gemini + --session-dir Path under ~/.codex/sessions or ~/.claude/projects --pr-number Open PR number to post comment to --repo Source repository or list filter --limit Maximum snapshots to list (default: 20) diff --git a/packages/docker-git-session-sync/src/core.ts b/packages/docker-git-session-sync/src/core.ts index 94ac4f04..528f9268 100644 --- a/packages/docker-git-session-sync/src/core.ts +++ b/packages/docker-git-session-sync/src/core.ts @@ -14,7 +14,7 @@ export const backupDefaultBranch = "main" export const chunkManifestSuffix = ".chunks.json" export const maxRepoFileSize = 99 * 1000 * 1000 export const maxPushBatchBytes = 50 * 1000 * 1000 -export const sessionDirNames: ReadonlyArray = [".codex", ".claude", ".qwen", ".gemini"] +export const sessionDirNames: ReadonlyArray = [".codex/sessions", ".claude/projects"] export const sessionWalkIgnoreDirNames: ReadonlySet = new Set([".git", "node_modules", "tmp"]) export const githubEnvKeys: ReadonlyArray = ["GITHUB_TOKEN", "GH_TOKEN"] @@ -64,13 +64,29 @@ export const buildBlobUrl = (repoFullName: string, branch: string, repoPath: str export const toSnapshotStamp = (createdAt: string): string => createdAt.replaceAll(":", "-").replaceAll(".", "-") +const branchSlugPattern = /[^A-Za-z0-9._-]+/gu + +export const toBranchSnapshotSlug = (branch: string): string => { + const slug = branch.replace(branchSlugPattern, "-").replace(/^-+|-+$/gu, "") + return slug.length === 0 ? "detached" : slug +} + export const buildSnapshotRef = ( sourceRepo: string, prNumber: number | null, - commitSha: string, - createdAt: string + branch: string ): string => - `${sourceRepo}/pr-${prNumber === null ? "no-pr" : prNumber}/commit-${commitSha}/${toSnapshotStamp(createdAt)}` + prNumber === null + ? `${sourceRepo}/branch-${toBranchSnapshotSlug(branch)}/current` + : `${sourceRepo}/pr-${prNumber}/current` + +export const isChatTranscriptPath = (logicalName: string): boolean => { + const logicalPath = toLogicalRelativePath(logicalName) + return ( + logicalPath.startsWith(".codex/sessions/") + || logicalPath.startsWith(".claude/projects/") + ) && logicalPath.endsWith(".jsonl") +} export const buildCommitMessage = (source: SourceInfo): string => `session-backup: ${source.repo} ${source.branch} ${source.commitSha.slice(0, 12)} ${ diff --git a/packages/docker-git-session-sync/src/shell.ts b/packages/docker-git-session-sync/src/shell.ts index aa86c057..ffff93f6 100644 --- a/packages/docker-git-session-sync/src/shell.ts +++ b/packages/docker-git-session-sync/src/shell.ts @@ -591,6 +591,30 @@ const buildFileMapFromTreeEntries = (entries: ReadonlyArray): Map, + snapshotRef: string +): ReadonlyArray => { + const snapshotPrefix = `${snapshotRef}/` + return entries.filter((entry) => entry.path !== snapshotRef && !entry.path.startsWith(snapshotPrefix)) +} + +const mergeHashedTreeEntries = ( + entries: ReadonlyArray, + hashedEntries: ReadonlyArray<{ readonly repoPath: string; readonly sha: string }> +): ReadonlyArray => { + const fileMap = buildFileMapFromTreeEntries(entries) + for (const entry of hashedEntries) { + fileMap.set(entry.repoPath, { mode: "100644", type: "blob", sha: entry.sha }) + } + return Array.from(fileMap.entries()).map(([entryPath, entry]) => ({ + path: entryPath, + mode: entry.mode, + type: entry.type, + sha: entry.sha + })) +} + const addChild = (childrenByDir: Map>, dirPath: string, child: NamedTreeEntry): void => { const current = childrenByDir.get(dirPath) ?? [] current.push(child) @@ -671,7 +695,10 @@ export const uploadSnapshot = ( const gitEnv = buildGitPushEnv(ghEnv, token) initializeUploadRepo(repoDir, backupRepo, gitEnv) let headSha = fetchRemoteBranchTip(repoDir, backupRepo.defaultBranch, gitEnv) - let existingEntries = getTreeEntriesForCommit(backupRepo.fullName, headSha, ghEnv).entries + let existingEntries = removeSnapshotTreeEntries( + getTreeEntriesForCommit(backupRepo.fullName, headSha, ghEnv).entries, + snapshotRef + ) let lastCommitSha = headSha let shouldRetry = false for (let batchIndex = 0; batchIndex < uploadBatches.length; batchIndex += 1) { @@ -702,9 +729,7 @@ export const uploadSnapshot = ( } headSha = commitSha lastCommitSha = commitSha - existingEntries = existingEntries.concat( - hashedEntries.map((entry) => ({ path: entry.repoPath, mode: "100644", type: "blob", sha: entry.sha })) - ) + existingEntries = mergeHashedTreeEntries(existingEntries, hashedEntries) } if (!shouldRetry) { return { diff --git a/packages/docker-git-session-sync/src/snapshots.ts b/packages/docker-git-session-sync/src/snapshots.ts index d525efca..ae31d631 100644 --- a/packages/docker-git-session-sync/src/snapshots.ts +++ b/packages/docker-git-session-sync/src/snapshots.ts @@ -183,9 +183,7 @@ export const downloadSnapshot = (options: DownloadOptions, cwd: string, output: output.out(`Downloaded snapshot to: ${outputPath}`) output.out("\nTo restore session files, copy them to the appropriate location:") - output.out(" - .codex/... -> ~/.codex/") - output.out(" - .claude/... -> ~/.claude/") - output.out(" - .qwen/... -> ~/.qwen/") - output.out(" - .gemini/... -> ~/.gemini/") + output.out(" - .codex/sessions/... -> ~/.codex/sessions/") + output.out(" - .claude/projects/... -> ~/.claude/projects/") return 0 } diff --git a/packages/docker-git-session-sync/tests/session-files.test.ts b/packages/docker-git-session-sync/tests/session-files.test.ts index 2ef10c61..c8ba4d9a 100644 --- a/packages/docker-git-session-sync/tests/session-files.test.ts +++ b/packages/docker-git-session-sync/tests/session-files.test.ts @@ -3,9 +3,11 @@ import os from "node:os" import path from "node:path" import { afterEach, beforeEach, describe, expect, it } from "vitest" -import { shouldIgnoreSessionPath } from "../src/core.js" +import { buildSnapshotRef, isChatTranscriptPath, shouldIgnoreSessionPath } from "../src/core.js" import { collectSessionFiles, type Output } from "../src/backup.js" import { parseArgs } from "../src/cli.js" +import { removeSnapshotTreeEntries } from "../src/shell.js" +import type { TreeEntry } from "../src/types.js" const output: Output = { out: () => undefined, @@ -23,26 +25,37 @@ afterEach(() => { }) describe("session path filtering", () => { - it("ignores tmp directories while keeping persistent session files", () => { + it("keeps only known agent chat transcripts", () => { const codexDir = path.join(tmpDir, ".codex") const claudeDir = path.join(tmpDir, ".claude") + const geminiDir = path.join(tmpDir, ".gemini") fs.mkdirSync(path.join(codexDir, "tmp"), { recursive: true }) - fs.mkdirSync(path.join(codexDir, "memory"), { recursive: true }) - fs.mkdirSync(path.join(claudeDir, "profiles"), { recursive: true }) + fs.mkdirSync(path.join(codexDir, "sessions", "2026", "04", "26"), { recursive: true }) + fs.mkdirSync(path.join(claudeDir, "projects", "-workspace"), { recursive: true }) + fs.mkdirSync(geminiDir, { recursive: true }) fs.writeFileSync(path.join(codexDir, "history.jsonl"), "{}\n") + fs.writeFileSync(path.join(codexDir, "config.toml"), "[tools]\n") + fs.writeFileSync(path.join(codexDir, "sessions", "2026", "04", "26", "rollout.jsonl"), "{}\n") fs.writeFileSync(path.join(codexDir, "tmp", "session.lock"), "lock") - fs.writeFileSync(path.join(codexDir, "memory", "notes.md"), "# notes\n") - fs.writeFileSync(path.join(claudeDir, "profiles", "default.json"), "{}") + fs.writeFileSync(path.join(claudeDir, "CLAUDE.md"), "# notes\n") + fs.writeFileSync(path.join(claudeDir, "projects", "-workspace", "chat.jsonl"), "{}\n") + fs.writeFileSync(path.join(geminiDir, "settings.json"), "{}") const logicalNames = [ ...collectSessionFiles(codexDir, ".codex", false, output), - ...collectSessionFiles(claudeDir, ".claude", false, output) - ].map((file) => file.logicalName) + ...collectSessionFiles(claudeDir, ".claude", false, output), + ...collectSessionFiles(geminiDir, ".gemini", false, output) + ].map((file) => file.logicalName).sort() - expect(logicalNames).toContain(".codex/history.jsonl") - expect(logicalNames).toContain(".codex/memory/notes.md") - expect(logicalNames).toContain(".claude/profiles/default.json") + expect(logicalNames).toEqual([ + ".claude/projects/-workspace/chat.jsonl", + ".codex/sessions/2026/04/26/rollout.jsonl" + ]) + expect(logicalNames).not.toContain(".codex/history.jsonl") + expect(logicalNames).not.toContain(".codex/config.toml") expect(logicalNames).not.toContain(".codex/tmp/session.lock") + expect(logicalNames).not.toContain(".claude/CLAUDE.md") + expect(logicalNames).not.toContain(".gemini/settings.json") }) it("treats nested tmp segments as ignored paths", () => { @@ -51,6 +64,58 @@ describe("session path filtering", () => { expect(shouldIgnoreSessionPath("memory/tmp/session.lock")).toBe(true) expect(shouldIgnoreSessionPath("memory/notes.md")).toBe(false) }) + + it("recognizes only Codex and Claude transcript paths", () => { + expect(isChatTranscriptPath(".codex/sessions/2026/04/26/rollout.jsonl")).toBe(true) + expect(isChatTranscriptPath(".claude/projects/-workspace/chat.jsonl")).toBe(true) + expect(isChatTranscriptPath(".codex/history.jsonl")).toBe(false) + expect(isChatTranscriptPath(".claude/projects/-workspace/settings.json")).toBe(false) + expect(isChatTranscriptPath(".gemini/sessions/chat.jsonl")).toBe(false) + }) +}) + +describe("snapshot refs", () => { + it("uses stable current refs for PR and branch snapshots", () => { + expect(buildSnapshotRef("org/repo", 230, "issue-230")).toBe("org/repo/pr-230/current") + expect(buildSnapshotRef("org/repo", null, "feature/session sync")).toBe("org/repo/branch-feature-session-sync/current") + }) +}) + +describe("snapshot tree replacement", () => { + it("removes only files under the exact current snapshot prefix", () => { + const entries: ReadonlyArray = [ + { + path: "org/repo/pr-230/current/.codex/sessions/old.jsonl", + mode: "100644", + type: "blob", + sha: "old" + }, + { + path: "org/repo/pr-230/current-old/.codex/sessions/keep.jsonl", + mode: "100644", + type: "blob", + sha: "keep-neighbor" + }, + { + path: "org/repo/pr-230/2026-04-26/manifest.json", + mode: "100644", + type: "blob", + sha: "keep-legacy" + }, + { + path: "org/repo/pr-231/current/.codex/sessions/keep.jsonl", + mode: "100644", + type: "blob", + sha: "keep-other-pr" + } + ] + + expect(removeSnapshotTreeEntries(entries, "org/repo/pr-230/current").map((entry) => entry.path)).toEqual([ + "org/repo/pr-230/current-old/.codex/sessions/keep.jsonl", + "org/repo/pr-230/2026-04-26/manifest.json", + "org/repo/pr-231/current/.codex/sessions/keep.jsonl" + ]) + }) }) describe("CLI parser", () => { From 95b3b38e5ba3544409e6d2e67134c957f7c3ddec Mon Sep 17 00:00:00 2001 From: skulidropek <66840575+skulidropek@users.noreply.github.com> Date: Mon, 27 Apr 2026 07:31:58 +0000 Subject: [PATCH 4/5] fix: harden session backup upload --- .../git-post-push-wrapper.ts | 6 +- .../src/lib/core/templates-entrypoint/git.ts | 16 +- .../docker-git-session-sync/src/backup.ts | 558 +++++++++++++++--- packages/docker-git-session-sync/src/cli.ts | 74 ++- packages/docker-git-session-sync/src/core.ts | 26 +- packages/docker-git-session-sync/src/shell.ts | 484 ++++++++------- packages/docker-git-session-sync/src/types.ts | 20 + .../tests/session-files.test.ts | 227 ++++++- .../git-post-push-wrapper.ts | 6 +- .../lib/src/core/templates-entrypoint/git.ts | 16 +- .../tests/core/git-post-push-wrapper.test.ts | 24 +- packages/lib/tests/core/templates.test.ts | 5 +- 12 files changed, 1078 insertions(+), 384 deletions(-) diff --git a/packages/app/src/lib/core/templates-entrypoint/git-post-push-wrapper.ts b/packages/app/src/lib/core/templates-entrypoint/git-post-push-wrapper.ts index 22214a76..5d30822e 100644 --- a/packages/app/src/lib/core/templates-entrypoint/git-post-push-wrapper.ts +++ b/packages/app/src/lib/core/templates-entrypoint/git-post-push-wrapper.ts @@ -137,9 +137,9 @@ docker_git_post_push_action() { if [[ -x "$DOCKER_GIT_POST_PUSH_ACTION" ]]; then if repo_root="$(docker_git_git_resolve_repo_root "$@")" && [[ -n "$repo_root" ]]; then - DOCKER_GIT_POST_PUSH_REPO_ROOT="$repo_root" DOCKER_GIT_SKIP_POST_PUSH_ACTION=1 "$DOCKER_GIT_POST_PUSH_ACTION" || true + DOCKER_GIT_POST_PUSH_REPO_ROOT="$repo_root" DOCKER_GIT_SKIP_POST_PUSH_ACTION=1 "$DOCKER_GIT_POST_PUSH_ACTION" else - DOCKER_GIT_SKIP_POST_PUSH_ACTION=1 "$DOCKER_GIT_POST_PUSH_ACTION" || true + DOCKER_GIT_SKIP_POST_PUSH_ACTION=1 "$DOCKER_GIT_POST_PUSH_ACTION" fi fi } @@ -153,7 +153,7 @@ if subcommand="$(docker_git_git_subcommand "$@")" && [[ "$subcommand" == "push" fi if [[ "$status" -eq 0 ]] && ! docker_git_git_push_is_dry_run "$@"; then - docker_git_post_push_action "$@" + docker_git_post_push_action "$@" || status=$? fi exit "$status" diff --git a/packages/app/src/lib/core/templates-entrypoint/git.ts b/packages/app/src/lib/core/templates-entrypoint/git.ts index 9e8ffadd..b9b0a996 100644 --- a/packages/app/src/lib/core/templates-entrypoint/git.ts +++ b/packages/app/src/lib/core/templates-entrypoint/git.ts @@ -279,15 +279,15 @@ cd "$REPO_ROOT" # invokes this after a successful git push # REF: issue-192 if [ "${"${"}DOCKER_GIT_SKIP_SESSION_BACKUP:-}" != "1" ]; then - if command -v gh >/dev/null 2>&1; then - if command -v docker-git-session-sync >/dev/null 2>&1; then - DOCKER_GIT_SKIP_POST_PUSH_ACTION=1 docker-git-session-sync backup --verbose || echo "[session-backup] Warning: session backup failed (non-fatal)" - else - echo "[session-backup] Warning: docker-git-session-sync not found (skipping session backup)" - fi - else - echo "[session-backup] Warning: gh CLI not found (skipping session backup)" + if ! command -v gh >/dev/null 2>&1; then + echo "[session-backup] Error: gh CLI not found" + exit 1 + fi + if ! command -v docker-git-session-sync >/dev/null 2>&1; then + echo "[session-backup] Error: docker-git-session-sync not found" + exit 1 fi + DOCKER_GIT_SKIP_POST_PUSH_ACTION=1 docker-git-session-sync backup --verbose --background --require-comment fi EOF chmod 0755 "$POST_PUSH_ACTION" diff --git a/packages/docker-git-session-sync/src/backup.ts b/packages/docker-git-session-sync/src/backup.ts index 052428ff..ad6bdeb3 100644 --- a/packages/docker-git-session-sync/src/backup.ts +++ b/packages/docker-git-session-sync/src/backup.ts @@ -1,7 +1,7 @@ import fs from "node:fs" import os from "node:os" import path from "node:path" -import { spawnSync } from "node:child_process" +import { spawn, spawnSync } from "node:child_process" import { buildBlobUrl, @@ -19,8 +19,18 @@ import { summarizeFiles, toLogicalRelativePath } from "./core.js" -import { ensureBackupRepo, prepareUploadArtifacts, resolveGhEnvironment, runGitCapture, uploadSnapshot } from "./shell.js" -import type { GhEnv, Log, SessionFile } from "./types.js" +import { + createPrComment, + ensureBackupRepo, + gitBlobShaForFile, + prepareUploadArtifacts, + resolveGhEnvironment, + runGitCapture, + updatePrComment, + uploadSnapshot +} from "./shell.js" +import { errorMessage, isRecord, numberField, recordField, stringField } from "./json.js" +import type { GhEnv, Log, PrComment, SessionFile, SourceInfo, UploadEntry } from "./types.js" export interface BackupOptions { readonly sessionDir: string | null @@ -29,6 +39,14 @@ export interface BackupOptions { readonly postComment: boolean readonly dryRun: boolean readonly verbose: boolean + readonly background: boolean + readonly requireComment: boolean +} + +export interface UploadOptions { + readonly contextPath: string + readonly readyFilePath: string | null + readonly verbose: boolean } export interface Output { @@ -303,165 +321,446 @@ export const collectSessionFiles = (dirPath: string, baseName: string, verbose: return sortSessionFiles(files) } -const postPrComment = ( - repo: string, - prNumber: number, - comment: string, - verbose: boolean, - output: Output, - ghEnv: GhEnv -): boolean => { - logVerbose(verbose, output, `Posting comment to PR #${prNumber}`) - const result = ghPrCommand(["pr", "comment", prNumber.toString(), "--repo", repo, "--body", comment], ghEnv) - if (!result.success) { - output.err("[session-backup] Failed to post PR comment") - return false +type PrContext = { readonly repo: string; readonly prNumber: number } + +type PrCommentContext = { + readonly repo: string + readonly comment: PrComment +} + +type ResolvedBackupContext = { + readonly source: SourceInfo + readonly snapshotRef: string + readonly gitStatus: string | null + readonly prContext: PrContext | null +} + +export type SessionUploadContext = { + readonly version: 1 + readonly cwd: string + readonly sessionDir: string | null + readonly source: SourceInfo + readonly snapshotRef: string + readonly gitStatus: string | null + readonly prComment: PrCommentContext | null + readonly verbose: boolean +} + +const nullableStringField = (value: unknown, key: string): string | null | undefined => { + if (!isRecord(value)) { + return undefined } - logVerbose(verbose, output, "Comment posted successfully") - return true + const field = value[key] + return typeof field === "string" || field === null ? field : undefined } -export const backupSessions = (options: BackupOptions, cwd: string, output: Output): number => { - if (process.env["DOCKER_GIT_SKIP_SESSION_BACKUP"] === "1") { - output.out("[session-backup] Skipped (DOCKER_GIT_SKIP_SESSION_BACKUP=1)") - return 0 +const nullableNumberField = (value: unknown, key: string): number | null | undefined => { + if (!isRecord(value)) { + return undefined + } + const field = value[key] + return typeof field === "number" || field === null ? field : undefined +} + +const booleanField = (value: unknown, key: string): boolean | null => { + if (!isRecord(value)) { + return null } + const field = value[key] + return typeof field === "boolean" ? field : null +} - const verbose = options.verbose - const ghEnv = resolveGhEnvironment(cwd, (message) => logVerbose(verbose, output, message)) - logVerbose(verbose, output, "Starting session backup...") +const parseSourceInfo = (value: unknown): SourceInfo | null => { + const repo = stringField(value, "repo") + const branch = stringField(value, "branch") + const prNumber = nullableNumberField(value, "prNumber") + const commitSha = stringField(value, "commitSha") + const createdAt = stringField(value, "createdAt") + return repo === null || branch === null || prNumber === undefined || commitSha === null || createdAt === null + ? null + : { repo, branch, prNumber, commitSha, createdAt } +} + +const parsePrCommentContext = (value: unknown): PrCommentContext | null => { + if (value === null) { + return null + } + const repo = stringField(value, "repo") + const comment = recordField(value, "comment") + const id = numberField(comment, "id") + const url = stringField(comment, "url") + return repo === null || id === null || url === null ? null : { repo, comment: { id, url } } +} + +export const parseUploadContext = (value: unknown): SessionUploadContext | null => { + const version = numberField(value, "version") + const cwd = stringField(value, "cwd") + const sessionDir = nullableStringField(value, "sessionDir") + const source = parseSourceInfo(recordField(value, "source")) + const snapshotRef = stringField(value, "snapshotRef") + const gitStatus = nullableStringField(value, "gitStatus") + const prComment = parsePrCommentContext(isRecord(value) ? value["prComment"] : undefined) + const verbose = booleanField(value, "verbose") + if ( + version !== 1 || + cwd === null || + sessionDir === undefined || + source === null || + snapshotRef === null || + gitStatus === undefined || + prComment === null && isRecord(value) && value["prComment"] !== null || + verbose === null + ) { + return null + } + return { version, cwd, sessionDir, source, snapshotRef, gitStatus, prComment, verbose } +} +const resolveBackupContext = ( + options: BackupOptions, + cwd: string, + ghEnv: GhEnv, + output: Output +): ResolvedBackupContext | null => { + const verbose = options.verbose const repoCandidates = getRepoCandidates(cwd, options.repo, verbose, output) if (repoCandidates.length === 0) { output.err("[session-backup] Could not determine source repository. Use --repo option.") - return 1 + return null } const sourceRepo = repoCandidates[0] if (sourceRepo === undefined) { - return 1 + return null } logVerbose(verbose, output, `Repository: ${sourceRepo}`) const branch = runGitCapture(cwd, ["rev-parse", "--abbrev-ref", "HEAD"]) if (branch === null || branch.length === 0) { output.err("[session-backup] Could not determine current branch.") - return 1 + return null } logVerbose(verbose, output, `Branch: ${branch}`) const commitSha = runGitCapture(cwd, ["rev-parse", "HEAD"]) if (commitSha === null || commitSha.length === 0) { output.err("[session-backup] Could not determine current commit.") - return 1 + return null } - let prContext: { readonly repo: string; readonly prNumber: number } | null = null + let prContext: PrContext | null = null if (options.prNumber !== null) { if (prIsOpen(sourceRepo, options.prNumber, ghEnv)) { prContext = { repo: sourceRepo, prNumber: options.prNumber } } else { logVerbose(verbose, output, `Skipping PR comment: PR #${options.prNumber} is not open`) } - } else if (options.postComment) { + } else if (options.postComment || options.requireComment) { prContext = findPrContext(repoCandidates, branch, verbose, output, ghEnv) } if (prContext !== null) { logVerbose(verbose, output, `PR number: ${prContext.prNumber} (${prContext.repo})`) - } else if (options.postComment) { - logVerbose(verbose, output, "No PR found for current branch, skipping comment") + } else if (options.postComment || options.requireComment) { + logVerbose(verbose, output, "No PR found for current branch") } - const sessionDirs = findSessionDirs(options.sessionDir, verbose, output) - if (sessionDirs.length === 0) { - logVerbose(verbose, output, "No session directories found") - return 0 + const source = { + repo: sourceRepo, + branch, + prNumber: prContext?.prNumber ?? null, + commitSha, + createdAt: new Date().toISOString() } + return { + source, + snapshotRef: buildSnapshotRef(sourceRepo, source.prNumber, branch), + gitStatus: getGitStatus(cwd), + prContext + } +} - const sessionFiles = sessionDirs.flatMap((dir) => collectSessionFiles(dir.path, dir.name, verbose, output)) - logVerbose(verbose, output, `Total files to backup: ${sessionFiles.length}`) +const createQueuedComment = ( + resolved: ResolvedBackupContext, + verbose: boolean, + output: Output, + ghEnv: GhEnv +): PrCommentContext | null => { + if (resolved.prContext === null) { + return null + } + logVerbose(verbose, output, `Posting git status comment to PR #${resolved.prContext.prNumber}`) + const comment = createPrComment( + resolved.prContext.repo, + resolved.prContext.prNumber, + buildCommentBody({ source: resolved.source, upload: { state: "queued" }, gitStatus: resolved.gitStatus }), + ghEnv + ) + if (comment === null) { + output.err("[session-backup] Failed to post PR comment with git status") + return null + } + logVerbose(verbose, output, `Comment posted: ${comment.url}`) + return { repo: resolved.prContext.repo, comment } +} - const backupRepo = ensureBackupRepo(ghEnv, (message) => logVerbose(verbose, output, message), !options.dryRun) - if (backupRepo === null) { - output.err("[session-backup] Failed to resolve or create the private session backup repository") - return 1 +const updateUploadComment = ( + context: SessionUploadContext, + ghEnv: GhEnv, + output: Output, + upload: Parameters[0]["upload"] +): void => { + if (context.prComment === null) { + return + } + const updated = updatePrComment( + context.prComment.repo, + context.prComment.comment.id, + buildCommentBody({ source: context.source, upload, gitStatus: context.gitStatus }), + ghEnv + ) + if (!updated) { + output.err("[session-backup] Failed to update PR comment") } +} +const buildReadmeUploadEntry = (repoPath: string, sourcePath: string): UploadEntry => ({ + repoPath, + sourcePath, + type: "readme", + size: fs.statSync(sourcePath).size, + blobSha: gitBlobShaForFile(sourcePath) +}) + +const runSessionUpload = ( + context: SessionUploadContext, + ghEnv: GhEnv, + output: Output +): number => { + const verbose = context.verbose const tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), "session-sync-repo-")) try { - const snapshotCreatedAt = new Date().toISOString() - const snapshotRef = buildSnapshotRef(sourceRepo, prContext?.prNumber ?? null, branch) + const sessionDirs = findSessionDirs(context.sessionDir, verbose, output) + if (sessionDirs.length === 0) { + logVerbose(verbose, output, "No session directories found") + updateUploadComment(context, ghEnv, output, { state: "skipped", message: "No session directories found." }) + return 0 + } + + const sessionFiles = sessionDirs.flatMap((dir) => collectSessionFiles(dir.path, dir.name, verbose, output)) + logVerbose(verbose, output, `Total files to backup: ${sessionFiles.length}`) + if (sessionFiles.length === 0) { + updateUploadComment(context, ghEnv, output, { state: "skipped", message: "No chat transcripts found." }) + return 0 + } + + const backupRepo = ensureBackupRepo(ghEnv, (message) => logVerbose(verbose, output, message)) + if (backupRepo === null) { + throw new Error("Failed to resolve or create the private session backup repository") + } + const prepared = prepareUploadArtifacts( sessionFiles, - snapshotRef, + context.snapshotRef, backupRepo.fullName, backupRepo.defaultBranch, tmpDir, (message) => logVerbose(verbose, output, message) ) - const source = { - repo: sourceRepo, - branch, - prNumber: prContext?.prNumber ?? null, - commitSha, - createdAt: snapshotCreatedAt - } const summary = summarizeFiles(prepared.manifestFiles) const sessionRoots = sessionDirs.map((dir) => `~/${dir.name}`) - const manifestUrl = buildBlobUrl(backupRepo.fullName, backupRepo.defaultBranch, `${snapshotRef}/manifest.json`) - const readmeRepoPath = `${snapshotRef}/README.md` + const manifestUrl = buildBlobUrl(backupRepo.fullName, backupRepo.defaultBranch, `${context.snapshotRef}/manifest.json`) + const readmeRepoPath = `${context.snapshotRef}/README.md` const readmeUrl = buildBlobUrl(backupRepo.fullName, backupRepo.defaultBranch, readmeRepoPath) - const gitStatus = getGitStatus(cwd) const manifest = buildManifest({ backupRepo, - snapshotRef, - source, + snapshotRef: context.snapshotRef, + source: context.source, files: prepared.manifestFiles, - createdAt: snapshotCreatedAt + createdAt: context.source.createdAt }) const readmePath = path.join(tmpDir, "README.md") fs.writeFileSync( readmePath, - buildSnapshotReadme({ backupRepo, source, manifestUrl, summary, sessionRoots }), + buildSnapshotReadme({ backupRepo, source: context.source, manifestUrl, summary, sessionRoots }), "utf8" ) - const uploadEntries = [ - ...prepared.uploadEntries, - { - repoPath: readmeRepoPath, - sourcePath: readmePath, - type: "readme", - size: fs.statSync(readmePath).size - } - ] - if (options.dryRun) { - output.out(`[session-backup] dry-run: ${source.commitSha.slice(0, 12)} (${summary.fileCount} files, ${formatBytes(summary.totalBytes)})`) - printGitStatus(output, gitStatus) - logVerbose(verbose, output, `[dry-run] Upload target: ${backupRepo.fullName}:${snapshotRef}`) - logVerbose(verbose, output, `[dry-run] README URL: ${readmeUrl}`) - logVerbose(verbose, output, `[dry-run] Manifest URL: ${manifestUrl}`) - if (options.postComment && prContext !== null) { - logVerbose(verbose, output, `Would post comment to PR #${prContext.prNumber} in ${prContext.repo}:`) - logVerbose(verbose, output, buildCommentBody({ source, manifestUrl, readmeUrl, summary, gitStatus })) + const uploadEntries = [...prepared.uploadEntries, buildReadmeUploadEntry(readmeRepoPath, readmePath)] + logVerbose(verbose, output, `Uploading snapshot to ${backupRepo.fullName}:${context.snapshotRef}`) + const uploadResult = uploadSnapshot(backupRepo, context.snapshotRef, manifest, uploadEntries, ghEnv) + output.out(`[session-backup] ok: ${context.source.commitSha.slice(0, 12)} (${summary.fileCount} files, ${formatBytes(summary.totalBytes)})`) + printGitStatus(output, context.gitStatus) + logVerbose(verbose, output, `[session-backup] Uploaded snapshot to ${backupRepo.fullName}:${context.snapshotRef}`) + logVerbose(verbose, output, `[session-backup] Manifest: ${uploadResult.manifestUrl}`) + updateUploadComment(context, ghEnv, output, { + state: "success", + manifestUrl: uploadResult.manifestUrl, + readmeUrl, + summary + }) + return 0 + } catch (error) { + const message = errorMessage(error) + output.err(`[session-backup] ${message}`) + updateUploadComment(context, ghEnv, output, { state: "failed", message }) + return 1 + } finally { + fs.rmSync(tmpDir, { recursive: true, force: true }) + } +} + +const writeBackgroundContext = (context: SessionUploadContext): string => { + const contextPath = path.join(os.tmpdir(), `docker-git-session-upload-${Date.now()}-${Math.random().toString(16).slice(2)}.json`) + fs.writeFileSync(contextPath, `${JSON.stringify(context, null, 2)}\n`, "utf8") + return contextPath +} + +const currentEntrypointPath = (): string | null => { + const entrypoint = process.argv[1] + return entrypoint === undefined || entrypoint.length === 0 ? null : entrypoint +} + +type BackgroundReadyState = + | { readonly state: "started" } + | { readonly state: "failed"; readonly message: string } + +const backgroundReadyTimeoutMs = 10_000 +const backgroundReadyPollMs = 50 + +const sleepSync = (durationMs: number): void => { + Atomics.wait(new Int32Array(new SharedArrayBuffer(4)), 0, 0, durationMs) +} + +const writeBackgroundReadyState = (readyFilePath: string | null, state: BackgroundReadyState): void => { + if (readyFilePath === null) { + return + } + try { + fs.writeFileSync(readyFilePath, `${JSON.stringify(state)}\n`, "utf8") + } catch { + // The parent process also has a timeout fallback; failure to write this + // handshake file must not block updating the PR comment from the child. + } +} + +const parseBackgroundReadyState = (value: unknown): BackgroundReadyState | null => { + const state = stringField(value, "state") + if (state === "started") { + return { state } + } + if (state === "failed") { + const message = stringField(value, "message") + return message === null ? null : { state, message } + } + return null +} + +const readBackgroundReadyState = (readyFilePath: string): BackgroundReadyState | null => { + try { + return parseBackgroundReadyState(JSON.parse(fs.readFileSync(readyFilePath, "utf8"))) + } catch { + return null + } +} + +const waitForBackgroundReady = (readyFilePath: string): BackgroundReadyState | null => { + const deadline = Date.now() + backgroundReadyTimeoutMs + while (Date.now() < deadline) { + if (fs.existsSync(readyFilePath)) { + const state = readBackgroundReadyState(readyFilePath) + if (state !== null) { + return state } - return 0 } + sleepSync(Math.min(backgroundReadyPollMs, Math.max(1, deadline - Date.now()))) + } + return null +} - logVerbose(verbose, output, `Uploading snapshot to ${backupRepo.fullName}:${snapshotRef}`) - const uploadResult = uploadSnapshot(backupRepo, snapshotRef, manifest, uploadEntries, ghEnv) - output.out(`[session-backup] ok: ${source.commitSha.slice(0, 12)} (${summary.fileCount} files, ${formatBytes(summary.totalBytes)})`) - printGitStatus(output, gitStatus) - logVerbose(verbose, output, `[session-backup] Uploaded snapshot to ${backupRepo.fullName}:${snapshotRef}`) - logVerbose(verbose, output, `[session-backup] Manifest: ${uploadResult.manifestUrl}`) +const spawnBackgroundUpload = (context: SessionUploadContext, output: Output): boolean => { + const contextPath = writeBackgroundContext(context) + const readyFilePath = path.join(os.tmpdir(), `docker-git-session-upload-ready-${Date.now()}-${Math.random().toString(16).slice(2)}.json`) + const entrypoint = currentEntrypointPath() + const args = entrypoint === null + ? ["upload", "--context", contextPath, "--ready-file", readyFilePath] + : [entrypoint, "upload", "--context", contextPath, "--ready-file", readyFilePath] + if (context.verbose) { + args.push("--verbose") + } + const command = entrypoint === null ? "docker-git-session-sync" : process.execPath + try { + const child = spawn(command, args, { + cwd: context.cwd, + detached: true, + stdio: "ignore", + env: process.env + }) + child.once("error", (error) => { + output.err(`[session-backup] Background upload process error: ${errorMessage(error)}`) + }) + const readyState = waitForBackgroundReady(readyFilePath) + fs.rmSync(readyFilePath, { force: true }) + if (readyState === null) { + output.err("[session-backup] Background upload did not report readiness") + child.unref() + return false + } + if (readyState.state === "failed") { + output.err(`[session-backup] Background upload failed to start: ${readyState.message}`) + child.unref() + return false + } + child.unref() + return true + } catch (error) { + fs.rmSync(contextPath, { force: true }) + fs.rmSync(readyFilePath, { force: true }) + output.err(`[session-backup] Failed to start background upload: ${errorMessage(error)}`) + return false + } +} - if (options.postComment && prContext !== null) { - postPrComment( - prContext.repo, - prContext.prNumber, - buildCommentBody({ source, manifestUrl: uploadResult.manifestUrl, readmeUrl, summary, gitStatus }), +const runDryRun = ( + resolved: ResolvedBackupContext, + options: BackupOptions, + ghEnv: GhEnv, + output: Output +): number => { + const verbose = options.verbose + const backupRepo = ensureBackupRepo(ghEnv, (message) => logVerbose(verbose, output, message), false) + if (backupRepo === null) { + output.err("[session-backup] Failed to resolve the private session backup repository") + return 1 + } + const tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), "session-sync-repo-")) + try { + const sessionDirs = findSessionDirs(options.sessionDir, verbose, output) + const sessionFiles = sessionDirs.flatMap((dir) => collectSessionFiles(dir.path, dir.name, verbose, output)) + const prepared = prepareUploadArtifacts( + sessionFiles, + resolved.snapshotRef, + backupRepo.fullName, + backupRepo.defaultBranch, + tmpDir, + (message) => logVerbose(verbose, output, message) + ) + const summary = summarizeFiles(prepared.manifestFiles) + const manifestUrl = buildBlobUrl(backupRepo.fullName, backupRepo.defaultBranch, `${resolved.snapshotRef}/manifest.json`) + const readmeUrl = buildBlobUrl(backupRepo.fullName, backupRepo.defaultBranch, `${resolved.snapshotRef}/README.md`) + output.out(`[session-backup] dry-run: ${resolved.source.commitSha.slice(0, 12)} (${summary.fileCount} files, ${formatBytes(summary.totalBytes)})`) + printGitStatus(output, resolved.gitStatus) + logVerbose(verbose, output, `[dry-run] Upload target: ${backupRepo.fullName}:${resolved.snapshotRef}`) + logVerbose(verbose, output, `[dry-run] README URL: ${readmeUrl}`) + logVerbose(verbose, output, `[dry-run] Manifest URL: ${manifestUrl}`) + if (options.postComment && resolved.prContext !== null) { + logVerbose(verbose, output, `Would post comment to PR #${resolved.prContext.prNumber} in ${resolved.prContext.repo}:`) + logVerbose( verbose, output, - ghEnv + buildCommentBody({ + source: resolved.source, + upload: { state: "success", manifestUrl, readmeUrl, summary }, + gitStatus: resolved.gitStatus + }) ) } return 0 @@ -469,3 +768,80 @@ export const backupSessions = (options: BackupOptions, cwd: string, output: Outp fs.rmSync(tmpDir, { recursive: true, force: true }) } } + +export const backupSessions = (options: BackupOptions, cwd: string, output: Output): number => { + if (process.env["DOCKER_GIT_SKIP_SESSION_BACKUP"] === "1") { + output.out("[session-backup] Skipped (DOCKER_GIT_SKIP_SESSION_BACKUP=1)") + return 0 + } + + if (options.requireComment && !options.postComment) { + output.err("[session-backup] --require-comment cannot be used with --no-comment") + return 1 + } + + const verbose = options.verbose + const ghEnv = resolveGhEnvironment(cwd, (message) => logVerbose(verbose, output, message)) + logVerbose(verbose, output, "Starting session backup...") + const resolved = resolveBackupContext(options, cwd, ghEnv, output) + if (resolved === null) { + return 1 + } + + if (options.dryRun) { + return runDryRun(resolved, options, ghEnv, output) + } + + const comment = options.postComment ? createQueuedComment(resolved, verbose, output, ghEnv) : null + if (options.requireComment && comment === null) { + output.err("[session-backup] Required PR comment was not created") + return 1 + } + + const uploadContext: SessionUploadContext = { + version: 1, + cwd, + sessionDir: options.sessionDir, + source: resolved.source, + snapshotRef: resolved.snapshotRef, + gitStatus: resolved.gitStatus, + prComment: comment, + verbose + } + + if (options.background) { + const started = spawnBackgroundUpload(uploadContext, output) + if (!started) { + updateUploadComment(uploadContext, ghEnv, output, { state: "failed", message: "Failed to start background upload." }) + return 1 + } + output.out(`[session-backup] queued: ${resolved.source.commitSha.slice(0, 12)}`) + printGitStatus(output, resolved.gitStatus) + return 0 + } + + return runSessionUpload(uploadContext, ghEnv, output) +} + +export const uploadFromContext = (options: UploadOptions, cwd: string, output: Output): number => { + const contextPath = path.resolve(cwd, options.contextPath) + const readyFilePath = options.readyFilePath === null ? null : path.resolve(cwd, options.readyFilePath) + try { + const parsed = parseUploadContext(JSON.parse(fs.readFileSync(contextPath, "utf8"))) + if (parsed === null) { + writeBackgroundReadyState(readyFilePath, { state: "failed", message: "Invalid upload context" }) + output.err("[session-backup] Invalid upload context") + return 1 + } + const context: SessionUploadContext = { ...parsed, verbose: options.verbose || parsed.verbose } + writeBackgroundReadyState(readyFilePath, { state: "started" }) + const ghEnv = resolveGhEnvironment(context.cwd, (message) => logVerbose(context.verbose, output, message)) + return runSessionUpload(context, ghEnv, output) + } catch (error) { + writeBackgroundReadyState(readyFilePath, { state: "failed", message: errorMessage(error) }) + output.err(`[session-backup] ${errorMessage(error)}`) + return 1 + } finally { + fs.rmSync(contextPath, { force: true }) + } +} diff --git a/packages/docker-git-session-sync/src/cli.ts b/packages/docker-git-session-sync/src/cli.ts index 0bc94a0b..140d9a00 100644 --- a/packages/docker-git-session-sync/src/cli.ts +++ b/packages/docker-git-session-sync/src/cli.ts @@ -1,4 +1,4 @@ -import { backupSessions, type BackupOptions, type Output } from "./backup.js" +import { backupSessions, uploadFromContext, type BackupOptions, type Output, type UploadOptions } from "./backup.js" import { downloadSnapshot, listSnapshots, viewSnapshot } from "./snapshots.js" const defaultLimit = 20 @@ -6,6 +6,7 @@ const defaultOutputDir = "./.session-restore" const usageText = `Usage: docker-git-session-sync backup [options] + docker-git-session-sync upload --context [options] docker-git-session-sync list [options] docker-git-session-sync view [options] docker-git-session-sync download [options] @@ -17,13 +18,18 @@ Options: --limit Maximum snapshots to list (default: 20) --output Download directory (default: ./.session-restore) --no-comment Skip posting a PR comment after backup - --dry-run Show what backup would upload - --verbose Enable verbose logging - --help Show help` + --background Queue upload in a detached background process + --require-comment Fail unless the PR git status comment is created + --context Internal upload context path + --ready-file Internal background startup handshake path + --dry-run Show what backup would upload + --verbose Enable verbose logging + --help Show help` type ParsedCommand = | { readonly _tag: "Help" } | ({ readonly _tag: "Backup" } & BackupOptions) + | ({ readonly _tag: "Upload" } & UploadOptions) | { readonly _tag: "List"; readonly limit: number; readonly repo: string | null; readonly verbose: boolean } | { readonly _tag: "View"; readonly snapshotRef: string; readonly verbose: boolean } | { readonly _tag: "Download"; readonly snapshotRef: string; readonly outputDir: string; readonly verbose: boolean } @@ -55,13 +61,17 @@ const parseBackup = (args: ReadonlyArray): ParseResult => { postComment: boolean dryRun: boolean verbose: boolean + background: boolean + requireComment: boolean } = { sessionDir: null, prNumber: null, repo: null, postComment: true, dryRun: false, - verbose: false + verbose: false, + background: false, + requireComment: false } let index = 0 while (index < args.length) { @@ -107,6 +117,16 @@ const parseBackup = (args: ReadonlyArray): ParseResult => { index += 1 continue } + if (arg === "--background") { + options.background = true + index += 1 + continue + } + if (arg === "--require-comment") { + options.requireComment = true + index += 1 + continue + } if (arg === "--verbose") { options.verbose = true index += 1 @@ -117,6 +137,44 @@ const parseBackup = (args: ReadonlyArray): ParseResult => { return { _tag: "Ok", command: { _tag: "Backup", ...options } } } +const parseUpload = (args: ReadonlyArray): ParseResult => { + let contextPath: string | null = null + let readyFilePath: string | null = null + let verbose = false + let index = 0 + while (index < args.length) { + const arg = args[index] + if (arg === "--context") { + const value = nextValue(args, index, arg) + if (typeof value !== "string") { + return value + } + contextPath = value + index += 2 + continue + } + if (arg === "--ready-file") { + const value = nextValue(args, index, arg) + if (typeof value !== "string") { + return value + } + readyFilePath = value + index += 2 + continue + } + if (arg === "--verbose") { + verbose = true + index += 1 + continue + } + return { _tag: "Error", message: `unknown upload option ${arg ?? ""}` } + } + if (contextPath === null) { + return { _tag: "Error", message: "upload requires --context " } + } + return { _tag: "Ok", command: { _tag: "Upload", contextPath, readyFilePath, verbose } } +} + const parseList = (args: ReadonlyArray): ParseResult => { let limit = defaultLimit let repo: string | null = null @@ -213,6 +271,9 @@ export const parseArgs = (args: ReadonlyArray): ParseResult => { if (command === "backup") { return parseBackup(rest) } + if (command === "upload") { + return parseUpload(rest) + } if (command === "list") { return parseList(rest) } @@ -253,6 +314,9 @@ export const runCli = ( if (command._tag === "Backup") { return backupSessions(command, cwd, output) } + if (command._tag === "Upload") { + return uploadFromContext(command, cwd, output) + } if (command._tag === "List") { return listSnapshots(command, cwd, output) } diff --git a/packages/docker-git-session-sync/src/core.ts b/packages/docker-git-session-sync/src/core.ts index 528f9268..65b823da 100644 --- a/packages/docker-git-session-sync/src/core.ts +++ b/packages/docker-git-session-sync/src/core.ts @@ -2,6 +2,7 @@ import path from "node:path" import type { BackupRepo, + CommentUploadState, FileSummary, SessionFile, SnapshotManifest, @@ -12,7 +13,7 @@ import type { export const backupRepoName = "docker-git-sessions" export const backupDefaultBranch = "main" export const chunkManifestSuffix = ".chunks.json" -export const maxRepoFileSize = 99 * 1000 * 1000 +export const maxRepoFileSize = 20 * 1000 * 1000 export const maxPushBatchBytes = 50 * 1000 * 1000 export const sessionDirNames: ReadonlyArray = [".codex/sessions", ".claude/projects"] export const sessionWalkIgnoreDirNames: ReadonlySet = new Set([".git", "node_modules", "tmp"]) @@ -162,17 +163,30 @@ export const buildSnapshotReadme = (input: { export const buildCommentBody = (input: { readonly source: SourceInfo - readonly manifestUrl: string - readonly readmeUrl: string - readonly summary: FileSummary + readonly upload: CommentUploadState readonly gitStatus: string | null }): string => { const statusText = input.gitStatus === null ? "(unavailable)" : input.gitStatus + const uploadLines = (() => { + if (input.upload.state === "queued") { + return ["Status: queued"] + } + if (input.upload.state === "skipped") { + return ["Status: skipped", `Message: ${input.upload.message}`] + } + if (input.upload.state === "failed") { + return ["Status: failure", `Error: ${input.upload.message}`] + } + return [ + "Status: success", + `Files: ${input.upload.summary.fileCount} (${formatBytes(input.upload.summary.totalBytes)})`, + `Links: [README](${input.upload.readmeUrl}) | [Manifest](${input.upload.manifestUrl})` + ] + })() return [ "## AI Session Backup", `Commit: ${input.source.commitSha}`, - `Files: ${input.summary.fileCount} (${formatBytes(input.summary.totalBytes)})`, - `Links: [README](${input.readmeUrl}) | [Manifest](${input.manifestUrl})`, + ...uploadLines, "", "`git status`", "```", diff --git a/packages/docker-git-session-sync/src/shell.ts b/packages/docker-git-session-sync/src/shell.ts index ffff93f6..23924772 100644 --- a/packages/docker-git-session-sync/src/shell.ts +++ b/packages/docker-git-session-sync/src/shell.ts @@ -2,6 +2,7 @@ import fs from "node:fs" import os from "node:os" import path from "node:path" import { spawnSync } from "node:child_process" +import { createHash } from "node:crypto" import { backupDefaultBranch, @@ -12,15 +13,15 @@ import { chunkManifestSuffix, findGithubTokenInEnvText, githubEnvKeys, - maxPushBatchBytes, maxRepoFileSize } from "./core.js" -import { arrayField, errorMessage, isRecord, recordField, stringField } from "./json.js" +import { arrayField, errorMessage, isRecord, numberField, recordField, stringField } from "./json.js" import type { BackupRepo, GhEnv, Log, PreparedUploadArtifacts, + PrComment, SessionFile, SnapshotManifest, TreeEntry, @@ -29,7 +30,6 @@ import type { } from "./types.js" const ghMaxBufferBytes = 32 * 1024 * 1024 -const ghGitCredentialHelper = "!gh auth git-credential" const dockerGitConfigFile = "docker-git.json" const projectWalkIgnoreDirNames: ReadonlySet = new Set([".git", "node_modules", ".cache", "tmp"]) @@ -50,10 +50,6 @@ interface TreeFileEntry { readonly sha: string } -interface NamedTreeEntry extends TreeFileEntry { - readonly name: string -} - const commandResult = (status: number | null, stdout: string | Buffer, stderr: string | Buffer): CommandResult => ({ success: status === 0, status: status ?? 1, @@ -217,18 +213,6 @@ export const getTreeEntries = (repoFullName: string, branch: string, ghEnv: GhEn } } -const getTreeEntriesForCommit = (repoFullName: string, commitSha: string, ghEnv: GhEnv): TreeSnapshot => { - const treeSha = getCommitTreeSha(repoFullName, commitSha, ghEnv) - const result = ensureSuccess( - ghApiJson(`/repos/${repoFullName}/git/trees/${treeSha}?recursive=1`, ghEnv), - `failed to list tree for commit ${commitSha} in ${repoFullName}` - ) - return { - treeSha, - entries: arrayField(result.json, "tree").filter(isTreeEntry) - } -} - export const getFileContent = ( repoFullName: string, repoPath: string, @@ -247,6 +231,36 @@ export const getFileContent = ( return Buffer.from(content, "base64") } +const parsePrComment = (value: unknown): PrComment | null => { + const id = numberField(value, "id") + const url = stringField(value, "html_url") + return id === null || url === null ? null : { id, url } +} + +export const createPrComment = ( + repoFullName: string, + prNumber: number, + body: string, + ghEnv: GhEnv +): PrComment | null => { + const result = ghApiJson(`/repos/${repoFullName}/issues/${prNumber}/comments`, ghEnv, { + method: "POST", + body: { body } + }) + return result.success ? parsePrComment(result.json) : null +} + +export const updatePrComment = ( + repoFullName: string, + commentId: number, + body: string, + ghEnv: GhEnv +): boolean => + ghApi(`/repos/${repoFullName}/issues/comments/${commentId}`, ghEnv, { + method: "PATCH", + body: { body } + }).success + const getDockerGitProjectsRoot = (): string => { const configured = process.env["DOCKER_GIT_PROJECTS_ROOT"]?.trim() return configured && configured.length > 0 ? configured : path.join(os.homedir(), ".docker-git") @@ -345,6 +359,15 @@ export const resolveGhEnvironment = (repoRoot: string, log: Log): GhEnv => { return env } +export const gitBlobShaForBuffer = (content: Buffer): string => + createHash("sha1") + .update(`blob ${content.length}\0`) + .update(content) + .digest("hex") + +export const gitBlobShaForFile = (sourcePath: string): string => + gitBlobShaForBuffer(fs.readFileSync(sourcePath)) + const splitLargeFile = ( sourcePath: string, logicalName: string, @@ -403,6 +426,26 @@ const splitLargeFile = ( } } +const stageSessionFile = ( + sourcePath: string, + logicalName: string, + tmpDir: string, + log: Log +): { readonly sourcePath: string; readonly size: number } | null => { + const stagedPath = path.join(tmpDir, "session-files", ...logicalName.split("/")) + try { + fs.mkdirSync(path.dirname(stagedPath), { recursive: true }) + fs.copyFileSync(sourcePath, stagedPath) + return { + sourcePath: stagedPath, + size: fs.statSync(stagedPath).size + } + } catch (error) { + log(`Skipping session file ${logicalName}: ${errorMessage(error)}`) + return null + } +} + export const prepareUploadArtifacts = ( sessionFiles: ReadonlyArray, snapshotRef: string, @@ -414,20 +457,26 @@ export const prepareUploadArtifacts = ( const uploadEntries: Array = [] const manifestFiles: Array = [] for (const file of sessionFiles) { - if (file.size <= maxRepoFileSize) { + const staged = stageSessionFile(file.sourcePath, file.logicalName, tmpDir, log) + if (staged === null) { + continue + } + if (staged.size <= maxRepoFileSize) { const repoPath = `${snapshotRef}/${file.logicalName}` - uploadEntries.push({ repoPath, sourcePath: file.sourcePath, type: "file", size: file.size }) + const blobSha = gitBlobShaForFile(staged.sourcePath) + uploadEntries.push({ repoPath, sourcePath: staged.sourcePath, type: "file", size: staged.size, blobSha }) manifestFiles.push({ type: "file", name: file.logicalName, - size: file.size, + size: staged.size, repoPath, - url: buildBlobUrl(repoFullName, branch, repoPath) + url: buildBlobUrl(repoFullName, branch, repoPath), + blobSha }) continue } - log(`Splitting oversized file ${file.logicalName} (${file.size} bytes)`) - const split = splitLargeFile(file.sourcePath, file.logicalName, tmpDir) + log(`Splitting oversized file ${file.logicalName} (${staged.size} bytes)`) + const split = splitLargeFile(staged.sourcePath, file.logicalName, tmpDir) const chunkManifest = buildChunkManifest(file.logicalName, split.originalSize, split.partNames) const chunkManifestPath = path.join(tmpDir, split.manifestName) fs.mkdirSync(path.dirname(chunkManifestPath), { recursive: true }) @@ -435,15 +484,24 @@ export const prepareUploadArtifacts = ( const partEntries = split.partNames.map((partName) => { const partPath = path.join(tmpDir, partName) const repoPath = `${snapshotRef}/${partName}` - uploadEntries.push({ repoPath, sourcePath: partPath, type: "chunk-part", size: fs.statSync(partPath).size }) - return { name: partName, repoPath, url: buildBlobUrl(repoFullName, branch, repoPath) } + const blobSha = gitBlobShaForFile(partPath) + uploadEntries.push({ + repoPath, + sourcePath: partPath, + type: "chunk-part", + size: fs.statSync(partPath).size, + blobSha + }) + return { name: partName, repoPath, url: buildBlobUrl(repoFullName, branch, repoPath), blobSha } }) const chunkManifestRepoPath = `${snapshotRef}/${split.manifestName}` + const chunkManifestBlobSha = gitBlobShaForFile(chunkManifestPath) uploadEntries.push({ repoPath: chunkManifestRepoPath, sourcePath: chunkManifestPath, type: "chunk-manifest", - size: fs.statSync(chunkManifestPath).size + size: fs.statSync(chunkManifestPath).size, + blobSha: chunkManifestBlobSha }) manifestFiles.push({ type: "chunked", @@ -451,136 +509,20 @@ export const prepareUploadArtifacts = ( originalSize: split.originalSize, chunkManifestPath: chunkManifestRepoPath, chunkManifestUrl: buildBlobUrl(repoFullName, branch, chunkManifestRepoPath), + chunkManifestBlobSha, parts: partEntries }) } return { uploadEntries, manifestFiles } } -const splitUploadEntriesIntoBatches = (uploadEntries: ReadonlyArray): ReadonlyArray> => { - const batches: Array> = [] - let currentBatch: Array = [] - let currentBatchBytes = 0 - for (const entry of uploadEntries) { - if (currentBatch.length > 0 && currentBatchBytes + entry.size > maxPushBatchBytes) { - batches.push(currentBatch) - currentBatch = [] - currentBatchBytes = 0 - } - currentBatch.push(entry) - currentBatchBytes += entry.size - } - if (currentBatch.length > 0) { - batches.push(currentBatch) - } - return batches -} - -const runGitCommand = (repoDir: string, args: ReadonlyArray, env: GhEnv, input?: string): CommandResult => { - const result = spawnSync( - "git", - ["-c", "core.hooksPath=/dev/null", "-c", "protocol.version=2", "-C", repoDir, ...args], - { encoding: "utf8", stdio: ["pipe", "pipe", "pipe"], env, input } - ) - return commandResult(result.status, result.stdout ?? "", result.stderr ?? "") +type GitTreeChange = { + readonly path: string + readonly mode: "100644" + readonly type: "blob" + readonly sha: string | null } -const buildGitPushEnv = (ghEnv: GhEnv, token: string): GhEnv => ({ - ...ghEnv, - GH_TOKEN: token, - GITHUB_TOKEN: token, - GIT_AUTH_TOKEN: token, - GIT_TERMINAL_PROMPT: "0" -}) - -const initializeUploadRepo = (repoDir: string, backupRepo: BackupRepo, gitEnv: GhEnv): void => { - ensureSuccess(runGitCommand(repoDir, ["init", "-q"], gitEnv), `failed to init git repo ${repoDir}`) - ensureSuccess( - runGitCommand(repoDir, ["remote", "add", "origin", `https://github.com/${backupRepo.fullName}.git`], gitEnv), - `failed to configure git remote for ${backupRepo.fullName}` - ) -} - -const fetchRemoteBranchTip = (repoDir: string, branch: string, gitEnv: GhEnv): string => { - ensureSuccess( - runGitCommand( - repoDir, - [ - "-c", - `credential.helper=${ghGitCredentialHelper}`, - "fetch", - "--quiet", - "--no-tags", - "--depth=1", - "--filter=blob:none", - "origin", - `refs/heads/${branch}:refs/remotes/origin/${branch}` - ], - gitEnv - ), - `failed to fetch ${branch} tip from backup repository` - ) - return ensureSuccess( - runGitCommand(repoDir, ["rev-parse", `refs/remotes/origin/${branch}`], gitEnv), - `failed to resolve fetched ${branch} tip` - ).stdout -} - -const hashFileObject = (repoDir: string, sourcePath: string, gitEnv: GhEnv): string => - ensureSuccess(runGitCommand(repoDir, ["hash-object", "-w", sourcePath], gitEnv), `failed to hash ${sourcePath}`).stdout - -const createTreeObject = (repoDir: string, entries: ReadonlyArray, gitEnv: GhEnv): string => { - const body = entries - .slice() - .sort((left, right) => left.name.localeCompare(right.name)) - .map((entry) => `${entry.mode} ${entry.type} ${entry.sha}\t${entry.name}`) - .join("\n") - return ensureSuccess( - runGitCommand(repoDir, ["mktree", "--missing"], gitEnv, body.length > 0 ? `${body}\n` : ""), - "failed to create git tree" - ).stdout -} - -const createCommitObject = ( - repoDir: string, - treeSha: string, - parentSha: string, - message: string, - createdAt: string, - owner: string, - gitEnv: GhEnv -): string => { - const authorEmail = `${owner}@users.noreply.github.com` - const unixSeconds = Math.floor(new Date(createdAt).getTime() / 1000) - const commitBody = [ - `tree ${treeSha}`, - `parent ${parentSha}`, - `author ${owner} <${authorEmail}> ${unixSeconds} +0000`, - `committer ${owner} <${authorEmail}> ${unixSeconds} +0000`, - "", - message, - "" - ].join("\n") - return ensureSuccess( - runGitCommand(repoDir, ["hash-object", "-t", "commit", "-w", "--stdin"], gitEnv, commitBody), - "failed to create git commit" - ).stdout -} - -const updateLocalRef = (repoDir: string, refName: string, commitSha: string, gitEnv: GhEnv): void => { - ensureSuccess(runGitCommand(repoDir, ["update-ref", refName, commitSha], gitEnv), `failed to update local ref ${refName}`) -} - -const isNonFastForwardPushError = (result: CommandResult): boolean => - /non-fast-forward|fetch first|rejected/iu.test(`${result.stderr}\n${result.stdout}`) - -const pushCommitToBranch = (repoDir: string, sourceRef: string, branch: string, gitEnv: GhEnv): CommandResult => - runGitCommand( - repoDir, - ["-c", `credential.helper=${ghGitCredentialHelper}`, "push", "origin", `${sourceRef}:refs/heads/${branch}`], - gitEnv - ) - const buildFileMapFromTreeEntries = (entries: ReadonlyArray): Map => { const fileMap = new Map() for (const entry of entries) { @@ -599,74 +541,131 @@ export const removeSnapshotTreeEntries = ( return entries.filter((entry) => entry.path !== snapshotRef && !entry.path.startsWith(snapshotPrefix)) } -const mergeHashedTreeEntries = ( +export const buildSnapshotDeleteTreeEntries = ( entries: ReadonlyArray, - hashedEntries: ReadonlyArray<{ readonly repoPath: string; readonly sha: string }> -): ReadonlyArray => { - const fileMap = buildFileMapFromTreeEntries(entries) - for (const entry of hashedEntries) { - fileMap.set(entry.repoPath, { mode: "100644", type: "blob", sha: entry.sha }) + snapshotRef: string, + desiredPaths: ReadonlySet +): ReadonlyArray => { + const snapshotPrefix = `${snapshotRef}/` + return entries + .filter((entry) => entry.type !== "tree" && entry.path.startsWith(snapshotPrefix) && !desiredPaths.has(entry.path)) + .map((entry) => ({ path: entry.path, mode: "100644", type: "blob", sha: null })) +} + +const createGitBlob = (repoFullName: string, entry: UploadEntry, ghEnv: GhEnv): string => { + const content = fs.readFileSync(entry.sourcePath) + const result = ensureSuccess( + ghApiJson(`/repos/${repoFullName}/git/blobs`, ghEnv, { + method: "POST", + body: { + content: content.toString("base64"), + encoding: "base64" + } + }), + `failed to create blob for ${repoFullName}:${entry.repoPath}` + ) + const sha = stringField(result.json, "sha") + if (sha === null) { + throw new Error(`GitHub blob response missing sha for ${entry.repoPath}`) + } + if (sha !== entry.blobSha) { + throw new Error(`GitHub blob sha mismatch for ${entry.repoPath}`) } - return Array.from(fileMap.entries()).map(([entryPath, entry]) => ({ - path: entryPath, - mode: entry.mode, - type: entry.type, - sha: entry.sha - })) + return sha } -const addChild = (childrenByDir: Map>, dirPath: string, child: NamedTreeEntry): void => { - const current = childrenByDir.get(dirPath) ?? [] - current.push(child) - childrenByDir.set(dirPath, current) +const createGitTree = ( + repoFullName: string, + baseTreeSha: string, + changes: ReadonlyArray, + ghEnv: GhEnv +): string => { + const result = ensureSuccess( + ghApiJson(`/repos/${repoFullName}/git/trees`, ghEnv, { + method: "POST", + body: { + base_tree: baseTreeSha, + tree: changes + } + }), + `failed to create tree in ${repoFullName}` + ) + const sha = stringField(result.json, "sha") + if (sha === null) { + throw new Error(`GitHub tree response missing sha for ${repoFullName}`) + } + return sha } -const writeMergedTree = ( - repoDir: string, - existingEntries: ReadonlyArray, - newEntries: ReadonlyArray<{ readonly repoPath: string; readonly sha: string }>, - gitEnv: GhEnv +const createGitCommit = ( + backupRepo: BackupRepo, + parentSha: string, + treeSha: string, + source: SnapshotManifest["source"], + ghEnv: GhEnv ): string => { - const fileMap = buildFileMapFromTreeEntries(existingEntries) - for (const entry of newEntries) { - fileMap.set(entry.repoPath, { mode: "100644", type: "blob", sha: entry.sha }) - } - const directories = new Set([""]) - const childrenByDir = new Map>() - for (const [repoPath, entry] of fileMap.entries()) { - const segments = repoPath.split("/") - const name = segments.pop() - const dirPath = segments.join("/") - if (name === undefined || name.length === 0) { - continue - } - directories.add(dirPath) - for (let index = 1; index <= segments.length; index += 1) { - directories.add(segments.slice(0, index).join("/")) - } - addChild(childrenByDir, dirPath, { name, mode: entry.mode, type: entry.type, sha: entry.sha }) + const author = { + name: backupRepo.owner, + email: `${backupRepo.owner}@users.noreply.github.com`, + date: source.createdAt } - const orderedDirectories = Array.from(directories).sort((left, right) => { - const depthDiff = right.split("/").length - left.split("/").length - return depthDiff !== 0 ? depthDiff : right.localeCompare(left) + const result = ensureSuccess( + ghApiJson(`/repos/${backupRepo.fullName}/git/commits`, ghEnv, { + method: "POST", + body: { + message: buildCommitMessage(source), + tree: treeSha, + parents: [parentSha], + author, + committer: author + } + }), + `failed to create commit in ${backupRepo.fullName}` + ) + const sha = stringField(result.json, "sha") + if (sha === null) { + throw new Error(`GitHub commit response missing sha for ${backupRepo.fullName}`) + } + return sha +} + +const updateGitRef = (repoFullName: string, branch: string, commitSha: string, ghEnv: GhEnv): CommandResult => + ghApi(`/repos/${repoFullName}/git/refs/heads/${branch}`, ghEnv, { + method: "PATCH", + body: { + sha: commitSha, + force: false + } }) - for (const dirPath of orderedDirectories) { - if (dirPath.length === 0) { + +const isRefUpdateConflict = (result: CommandResult): boolean => + /409|Conflict|Reference update failed|fast[- ]forward/iu.test(`${result.stderr}\n${result.stdout}`) + +const buildUploadTreeChanges = ( + repoFullName: string, + snapshotRef: string, + existingEntries: ReadonlyArray, + desiredEntries: ReadonlyArray, + ghEnv: GhEnv +): ReadonlyArray => { + const existingFileMap = buildFileMapFromTreeEntries(existingEntries) + const desiredPaths = new Set(desiredEntries.map((entry) => entry.repoPath)) + const changes: Array = [] + for (const entry of desiredEntries) { + if (existingFileMap.get(entry.repoPath)?.sha === entry.blobSha) { continue } - const treeSha = createTreeObject(repoDir, childrenByDir.get(dirPath) ?? [], gitEnv) - const segments = dirPath.split("/") - const name = segments.pop() - if (name !== undefined && name.length > 0) { - addChild(childrenByDir, segments.join("/"), { name, mode: "040000", type: "tree", sha: treeSha }) - } + changes.push({ + path: entry.repoPath, + mode: "100644", + type: "blob", + sha: createGitBlob(repoFullName, entry, ghEnv) + }) } - return createTreeObject(repoDir, childrenByDir.get("") ?? [], gitEnv) + changes.push(...buildSnapshotDeleteTreeEntries(existingEntries, snapshotRef, desiredPaths)) + return changes } -const buildUploadCommitMessage = (source: SnapshotManifest["source"], batchIndex: number, batchCount: number): string => - batchCount <= 1 ? buildCommitMessage(source) : `${buildCommitMessage(source)} [batch ${batchIndex}/${batchCount}]` - export const uploadSnapshot = ( backupRepo: BackupRepo, snapshotRef: string, @@ -674,72 +673,53 @@ export const uploadSnapshot = ( uploadEntries: ReadonlyArray, ghEnv: GhEnv ): { readonly commitSha: string; readonly manifestPath: string; readonly manifestUrl: string } => { - const token = ghEnv["GITHUB_TOKEN"]?.trim() || ghEnv["GH_TOKEN"]?.trim() || "" - if (token.length === 0) { - throw new Error("GitHub token missing for backup repository push") - } - const uploadRoot = fs.mkdtempSync(path.join(os.tmpdir(), "session-backup-git-push-")) + const uploadRoot = fs.mkdtempSync(path.join(os.tmpdir(), "session-backup-api-")) const manifestPath = `${snapshotRef}/manifest.json` const manifestTempPath = path.join(uploadRoot, "manifest.json") fs.writeFileSync(manifestTempPath, `${JSON.stringify(snapshotManifest, null, 2)}\n`, "utf8") const manifestEntry = { repoPath: manifestPath, sourcePath: manifestTempPath, - size: fs.statSync(manifestTempPath).size + size: fs.statSync(manifestTempPath).size, + type: "manifest", + blobSha: gitBlobShaForFile(manifestTempPath) } - const uploadBatches = splitUploadEntriesIntoBatches([...uploadEntries, manifestEntry]) + const desiredEntries = [...uploadEntries, manifestEntry] try { for (let attempt = 1; attempt <= 3; attempt += 1) { - const repoDir = path.join(uploadRoot, `attempt-${attempt}`, "repo") - fs.mkdirSync(repoDir, { recursive: true }) - const gitEnv = buildGitPushEnv(ghEnv, token) - initializeUploadRepo(repoDir, backupRepo, gitEnv) - let headSha = fetchRemoteBranchTip(repoDir, backupRepo.defaultBranch, gitEnv) - let existingEntries = removeSnapshotTreeEntries( - getTreeEntriesForCommit(backupRepo.fullName, headSha, ghEnv).entries, - snapshotRef + const currentTree = getTreeEntries(backupRepo.fullName, backupRepo.defaultBranch, ghEnv) + if (currentTree.headSha === undefined) { + throw new Error(`failed to resolve ${backupRepo.fullName}@${backupRepo.defaultBranch} head`) + } + const changes = buildUploadTreeChanges( + backupRepo.fullName, + snapshotRef, + currentTree.entries, + desiredEntries, + ghEnv ) - let lastCommitSha = headSha - let shouldRetry = false - for (let batchIndex = 0; batchIndex < uploadBatches.length; batchIndex += 1) { - const batch = uploadBatches[batchIndex] ?? [] - const hashedEntries = batch.map((entry) => ({ - repoPath: entry.repoPath, - sha: hashFileObject(repoDir, entry.sourcePath, gitEnv) - })) - const nextTreeSha = writeMergedTree(repoDir, existingEntries, hashedEntries, gitEnv) - const commitSha = createCommitObject( - repoDir, - nextTreeSha, - headSha, - buildUploadCommitMessage(snapshotManifest.source, batchIndex + 1, uploadBatches.length), - snapshotManifest.source.createdAt, - backupRepo.owner, - gitEnv - ) - const localRef = `refs/heads/session-backup-upload-${attempt}-${batchIndex + 1}` - updateLocalRef(repoDir, localRef, commitSha, gitEnv) - const pushResult = pushCommitToBranch(repoDir, localRef, backupRepo.defaultBranch, gitEnv) - if (!pushResult.success) { - if (attempt < 3 && isNonFastForwardPushError(pushResult)) { - shouldRetry = true - break - } - throw new Error(`failed to push backup commit: ${pushResult.stderr || pushResult.stdout || `exit ${pushResult.status}`}`) + if (changes.length === 0) { + return { + commitSha: currentTree.headSha, + manifestPath, + manifestUrl: buildBlobUrl(backupRepo.fullName, backupRepo.defaultBranch, manifestPath) } - headSha = commitSha - lastCommitSha = commitSha - existingEntries = mergeHashedTreeEntries(existingEntries, hashedEntries) } - if (!shouldRetry) { + const treeSha = createGitTree(backupRepo.fullName, currentTree.treeSha, changes, ghEnv) + const commitSha = createGitCommit(backupRepo, currentTree.headSha, treeSha, snapshotManifest.source, ghEnv) + const updateResult = updateGitRef(backupRepo.fullName, backupRepo.defaultBranch, commitSha, ghEnv) + if (updateResult.success) { return { - commitSha: lastCommitSha, + commitSha, manifestPath, manifestUrl: buildBlobUrl(backupRepo.fullName, backupRepo.defaultBranch, manifestPath) } } + if (attempt >= 3 || !isRefUpdateConflict(updateResult)) { + throw new Error(`failed to update backup ref: ${updateResult.stderr || updateResult.stdout || `exit ${updateResult.status}`}`) + } } - throw new Error("failed to push backup commit after 3 attempts") + throw new Error("failed to update backup ref after 3 attempts") } finally { fs.rmSync(uploadRoot, { recursive: true, force: true }) } diff --git a/packages/docker-git-session-sync/src/types.ts b/packages/docker-git-session-sync/src/types.ts index 0635e887..9f174051 100644 --- a/packages/docker-git-session-sync/src/types.ts +++ b/packages/docker-git-session-sync/src/types.ts @@ -21,6 +21,7 @@ export interface UploadEntry { readonly sourcePath: string readonly type?: string readonly size: number + readonly blobSha: string } export interface SourceInfo { @@ -37,12 +38,14 @@ export interface ManifestFile { readonly size: number readonly repoPath: string readonly url: string + readonly blobSha?: string } export interface ChunkedManifestPart { readonly name: string readonly repoPath: string readonly url: string + readonly blobSha?: string } export interface ChunkedManifestFile { @@ -51,6 +54,7 @@ export interface ChunkedManifestFile { readonly originalSize: number readonly chunkManifestPath: string readonly chunkManifestUrl: string + readonly chunkManifestBlobSha?: string readonly parts: ReadonlyArray } @@ -78,6 +82,22 @@ export interface FileSummary { readonly totalBytes: number } +export type CommentUploadState = + | { readonly state: "queued" } + | { readonly state: "skipped"; readonly message: string } + | { + readonly state: "success" + readonly manifestUrl: string + readonly readmeUrl: string + readonly summary: FileSummary + } + | { readonly state: "failed"; readonly message: string } + +export interface PrComment { + readonly id: number + readonly url: string +} + export interface TreeEntry { readonly path: string readonly mode: string diff --git a/packages/docker-git-session-sync/tests/session-files.test.ts b/packages/docker-git-session-sync/tests/session-files.test.ts index c8ba4d9a..04f794a2 100644 --- a/packages/docker-git-session-sync/tests/session-files.test.ts +++ b/packages/docker-git-session-sync/tests/session-files.test.ts @@ -3,10 +3,21 @@ import os from "node:os" import path from "node:path" import { afterEach, beforeEach, describe, expect, it } from "vitest" -import { buildSnapshotRef, isChatTranscriptPath, shouldIgnoreSessionPath } from "../src/core.js" -import { collectSessionFiles, type Output } from "../src/backup.js" +import { + buildCommentBody, + buildSnapshotRef, + isChatTranscriptPath, + maxRepoFileSize, + shouldIgnoreSessionPath +} from "../src/core.js" +import { collectSessionFiles, parseUploadContext, uploadFromContext, type Output } from "../src/backup.js" import { parseArgs } from "../src/cli.js" -import { removeSnapshotTreeEntries } from "../src/shell.js" +import { + buildSnapshotDeleteTreeEntries, + gitBlobShaForBuffer, + prepareUploadArtifacts, + removeSnapshotTreeEntries +} from "../src/shell.js" import type { TreeEntry } from "../src/types.js" const output: Output = { @@ -81,6 +92,35 @@ describe("snapshot refs", () => { }) }) +describe("upload artifacts", () => { + it("keeps GitHub API blob payloads below the repository file limit", () => { + expect(maxRepoFileSize).toBeLessThanOrEqual(20 * 1000 * 1000) + }) + + it("stages session contents before hashing and upload", () => { + const codexDir = path.join(tmpDir, ".codex") + const sessionPath = path.join(codexDir, "sessions", "2026", "04", "27", "rollout.jsonl") + fs.mkdirSync(path.dirname(sessionPath), { recursive: true }) + fs.writeFileSync(sessionPath, "before\n") + + const sessionFiles = collectSessionFiles(codexDir, ".codex", false, output) + const prepared = prepareUploadArtifacts( + sessionFiles, + "org/repo/pr-230/current", + "backup-owner/docker-git-sessions", + "main", + tmpDir, + () => undefined + ) + fs.writeFileSync(sessionPath, "after\n") + + const entry = prepared.uploadEntries[0] + expect(entry?.repoPath).toBe("org/repo/pr-230/current/.codex/sessions/2026/04/27/rollout.jsonl") + expect(entry === undefined ? "" : fs.readFileSync(entry.sourcePath, "utf8")).toBe("before\n") + expect(entry?.blobSha).toBe(gitBlobShaForBuffer(Buffer.from("before\n"))) + }) +}) + describe("snapshot tree replacement", () => { it("removes only files under the exact current snapshot prefix", () => { const entries: ReadonlyArray = [ @@ -116,6 +156,91 @@ describe("snapshot tree replacement", () => { "org/repo/pr-231/current/.codex/sessions/keep.jsonl" ]) }) + + it("builds delete entries only for stale current snapshot files", () => { + const entries: ReadonlyArray = [ + { + path: "org/repo/pr-230/current/.codex/sessions/old.jsonl", + mode: "100644", + type: "blob", + sha: "old" + }, + { + path: "org/repo/pr-230/current/.codex/sessions/keep.jsonl", + mode: "100644", + type: "blob", + sha: "keep" + }, + { + path: "org/repo/pr-230/current-old/.codex/sessions/old.jsonl", + mode: "100644", + type: "blob", + sha: "neighbor" + } + ] + + expect( + buildSnapshotDeleteTreeEntries( + entries, + "org/repo/pr-230/current", + new Set(["org/repo/pr-230/current/.codex/sessions/keep.jsonl"]) + ) + ).toEqual([ + { + path: "org/repo/pr-230/current/.codex/sessions/old.jsonl", + mode: "100644", + type: "blob", + sha: null + } + ]) + }) +}) + +describe("PR comment body", () => { + const source = { + repo: "org/repo", + branch: "issue-230", + prNumber: 230, + commitSha: "0123456789abcdef", + createdAt: "2026-04-27T00:00:00.000Z" + } + + it("keeps git status in queued, success, and failure states", () => { + const gitStatus = "On branch issue-230\nChanges not staged for commit:\n modified: src/app.ts" + const gitStatusBlock = ["`git status`", "```", gitStatus, "```"].join("\n") + + const queuedBody = buildCommentBody({ source, upload: { state: "queued" }, gitStatus }) + const successBody = buildCommentBody({ + source, + upload: { + state: "success", + manifestUrl: "https://example.test/manifest", + readmeUrl: "https://example.test/readme", + summary: { fileCount: 2, totalBytes: 1234 } + }, + gitStatus + }) + const failureBody = buildCommentBody({ + source, + upload: { state: "failed", message: "upload failed" }, + gitStatus + }) + + expect(queuedBody).toContain("Status: queued") + expect(queuedBody).toContain(gitStatusBlock) + expect(successBody).toContain("Status: success") + expect(successBody).toContain("Links: [README](https://example.test/readme) | [Manifest](https://example.test/manifest)") + expect(successBody).toContain(gitStatusBlock) + expect(failureBody).toContain("Status: failure") + expect(failureBody).toContain("Error: upload failed") + expect(failureBody).toContain(gitStatusBlock) + }) + + it("marks unavailable git status explicitly", () => { + expect(buildCommentBody({ source, upload: { state: "queued" }, gitStatus: null })).toContain( + ["`git status`", "```", "(unavailable)", "```"].join("\n") + ) + }) }) describe("CLI parser", () => { @@ -129,13 +254,107 @@ describe("CLI parser", () => { repo: "org/repo", postComment: false, dryRun: false, - verbose: false + verbose: false, + background: false, + requireComment: false + } + }) + }) + + it("parses background backup options", () => { + expect(parseArgs(["backup", "--verbose", "--background", "--require-comment"])).toEqual({ + _tag: "Ok", + command: { + _tag: "Backup", + sessionDir: null, + prNumber: null, + repo: null, + postComment: true, + dryRun: false, + verbose: true, + background: true, + requireComment: true + } + }) + }) + + it("parses internal upload options", () => { + expect(parseArgs(["upload", "--context", "/tmp/session-upload.json", "--verbose"])).toEqual({ + _tag: "Ok", + command: { + _tag: "Upload", + contextPath: "/tmp/session-upload.json", + readyFilePath: null, + verbose: true } }) }) + it("parses internal upload readiness handshakes", () => { + expect(parseArgs(["upload", "--context", "/tmp/session-upload.json", "--ready-file", "/tmp/ready.json", "--verbose"])).toEqual({ + _tag: "Ok", + command: { + _tag: "Upload", + contextPath: "/tmp/session-upload.json", + readyFilePath: "/tmp/ready.json", + verbose: true + } + }) + }) + + it("parses background upload contexts with nested PR comment metadata", () => { + const parsed = parseUploadContext({ + version: 1, + cwd: "/workspace", + sessionDir: null, + source: { + repo: "org/repo", + branch: "issue-230", + prNumber: 230, + commitSha: "0123456789abcdef", + createdAt: "2026-04-27T00:00:00.000Z" + }, + snapshotRef: "org/repo/pr-230/current", + gitStatus: "dirty", + prComment: { + repo: "org/repo", + comment: { + id: 1001, + url: "https://example.test/comment" + } + }, + verbose: true + }) + + expect(parsed?.prComment?.comment.id).toBe(1001) + }) + it("rejects missing snapshot refs", () => { expect(parseArgs(["view"])).toEqual({ _tag: "Error", message: "view requires " }) expect(parseArgs(["download"])).toEqual({ _tag: "Error", message: "download requires " }) }) }) + +describe("background upload handshakes", () => { + it("reports invalid upload contexts to the parent process", () => { + const contextPath = path.join(tmpDir, "bad-context.json") + const readyFilePath = path.join(tmpDir, "ready.json") + const errors: Array = [] + fs.writeFileSync(contextPath, "{}\n", "utf8") + + expect(uploadFromContext({ + contextPath, + readyFilePath, + verbose: false + }, tmpDir, { + out: () => undefined, + err: (message) => errors.push(message) + })).toBe(1) + + expect(JSON.parse(fs.readFileSync(readyFilePath, "utf8"))).toEqual({ + state: "failed", + message: "Invalid upload context" + }) + expect(errors).toContain("[session-backup] Invalid upload context") + }) +}) diff --git a/packages/lib/src/core/templates-entrypoint/git-post-push-wrapper.ts b/packages/lib/src/core/templates-entrypoint/git-post-push-wrapper.ts index 2572d27a..679566c1 100644 --- a/packages/lib/src/core/templates-entrypoint/git-post-push-wrapper.ts +++ b/packages/lib/src/core/templates-entrypoint/git-post-push-wrapper.ts @@ -136,9 +136,9 @@ docker_git_post_push_action() { if [[ -x "$DOCKER_GIT_POST_PUSH_ACTION" ]]; then if repo_root="$(docker_git_git_resolve_repo_root "$@")" && [[ -n "$repo_root" ]]; then - DOCKER_GIT_POST_PUSH_REPO_ROOT="$repo_root" DOCKER_GIT_SKIP_POST_PUSH_ACTION=1 "$DOCKER_GIT_POST_PUSH_ACTION" || true + DOCKER_GIT_POST_PUSH_REPO_ROOT="$repo_root" DOCKER_GIT_SKIP_POST_PUSH_ACTION=1 "$DOCKER_GIT_POST_PUSH_ACTION" else - DOCKER_GIT_SKIP_POST_PUSH_ACTION=1 "$DOCKER_GIT_POST_PUSH_ACTION" || true + DOCKER_GIT_SKIP_POST_PUSH_ACTION=1 "$DOCKER_GIT_POST_PUSH_ACTION" fi fi } @@ -152,7 +152,7 @@ if subcommand="$(docker_git_git_subcommand "$@")" && [[ "$subcommand" == "push" fi if [[ "$status" -eq 0 ]] && ! docker_git_git_push_is_dry_run "$@"; then - docker_git_post_push_action "$@" + docker_git_post_push_action "$@" || status=$? fi exit "$status" diff --git a/packages/lib/src/core/templates-entrypoint/git.ts b/packages/lib/src/core/templates-entrypoint/git.ts index 10ad4459..641b7180 100644 --- a/packages/lib/src/core/templates-entrypoint/git.ts +++ b/packages/lib/src/core/templates-entrypoint/git.ts @@ -278,15 +278,15 @@ cd "$REPO_ROOT" # invokes this after a successful git push # REF: issue-192 if [ "${"${"}DOCKER_GIT_SKIP_SESSION_BACKUP:-}" != "1" ]; then - if command -v gh >/dev/null 2>&1; then - if command -v docker-git-session-sync >/dev/null 2>&1; then - DOCKER_GIT_SKIP_POST_PUSH_ACTION=1 docker-git-session-sync backup --verbose || echo "[session-backup] Warning: session backup failed (non-fatal)" - else - echo "[session-backup] Warning: docker-git-session-sync not found (skipping session backup)" - fi - else - echo "[session-backup] Warning: gh CLI not found (skipping session backup)" + if ! command -v gh >/dev/null 2>&1; then + echo "[session-backup] Error: gh CLI not found" + exit 1 + fi + if ! command -v docker-git-session-sync >/dev/null 2>&1; then + echo "[session-backup] Error: docker-git-session-sync not found" + exit 1 fi + DOCKER_GIT_SKIP_POST_PUSH_ACTION=1 docker-git-session-sync backup --verbose --background --require-comment fi EOF chmod 0755 "$POST_PUSH_ACTION" diff --git a/packages/lib/tests/core/git-post-push-wrapper.test.ts b/packages/lib/tests/core/git-post-push-wrapper.test.ts index 19b0b13d..cacdf7a2 100644 --- a/packages/lib/tests/core/git-post-push-wrapper.test.ts +++ b/packages/lib/tests/core/git-post-push-wrapper.test.ts @@ -97,6 +97,10 @@ if [[ -n "\${FAKE_NODE_SCRIPT_LOG_PATH:-}" ]]; then printf '%s\\n' "$*" >> "$FAKE_NODE_SCRIPT_LOG_PATH" fi +if [[ -n "\${FAKE_SESSION_SYNC_EXIT_CODE:-}" ]]; then + exit "$FAKE_SESSION_SYNC_EXIT_CODE" +fi + exit 0 ` @@ -292,7 +296,7 @@ describe("git post-push wrapper", () => { expect(nodeCwd).toEqual([harness.repoDir]) expect(nodeRepoRoot).toEqual([harness.repoDir]) - expect(nodeScript).toEqual(["backup --verbose"]) + expect(nodeScript).toEqual(["backup --verbose --background --require-comment"]) }) ).pipe(Effect.provide(NodeContext.layer))) @@ -308,7 +312,7 @@ describe("git post-push wrapper", () => { expect(nodeCwd).toEqual([harness.repoDir]) expect(nodeRepoRoot).toEqual([harness.repoDir]) - expect(nodeScript).toEqual(["backup --verbose"]) + expect(nodeScript).toEqual(["backup --verbose --background --require-comment"]) expect(gitLog.some((line) => line.startsWith(`${harness.externalDir}\t-C ${harness.repoDir} push`))).toBe(true) }) ).pipe(Effect.provide(NodeContext.layer))) @@ -353,4 +357,20 @@ describe("git post-push wrapper", () => { expect(nodeScript).toEqual([]) }) ).pipe(Effect.provide(NodeContext.layer))) + + it.effect("propagates post-push failures after a successful push", () => + withHarness((harness) => + Effect.gen(function*(_) { + yield* _( + runWrapper(harness, harness.repoDir, ["push", "origin", "HEAD"], { + env: { FAKE_SESSION_SYNC_EXIT_CODE: "23" }, + okExitCodes: [23] + }) + ) + + const nodeScript = yield* _(readLogLines(harness.nodeScriptLogPath)) + + expect(nodeScript).toEqual(["backup --verbose --background --require-comment"]) + }) + ).pipe(Effect.provide(NodeContext.layer))) }) diff --git a/packages/lib/tests/core/templates.test.ts b/packages/lib/tests/core/templates.test.ts index 2d5bb757..51356367 100644 --- a/packages/lib/tests/core/templates.test.ts +++ b/packages/lib/tests/core/templates.test.ts @@ -76,11 +76,12 @@ describe("renderEntrypointGitHooks", () => { expect(hooks).not.toContain('POST_PUSH_RUNTIME="/etc/profile.d/zz-git-post-push.sh"') expect(hooks).not.toContain("source /etc/profile.d/zz-git-post-push.sh") expect(hooks).toContain('REPO_ROOT="${DOCKER_GIT_POST_PUSH_REPO_ROOT:-}"') - expect(hooks).toContain("docker-git-session-sync backup --verbose") + expect(hooks).toContain("docker-git-session-sync backup --verbose --background --require-comment") expect(hooks).toContain("docker-git-session-sync not found") + expect(hooks).not.toContain("session backup failed (non-fatal)") expect(hooks).not.toContain("node \"$BACKUP_SCRIPT\"") expect(hooks).not.toContain("session-backup-gist.js") - expect(hooks).toContain("[session-backup] Warning: gh CLI not found") + expect(hooks).toContain("[session-backup] Error: gh CLI not found") }) }) From ab53ace79b6debb57ef39bdb4c486e2550c928f4 Mon Sep 17 00:00:00 2001 From: skulidropek <66840575+skulidropek@users.noreply.github.com> Date: Mon, 27 Apr 2026 10:10:45 +0000 Subject: [PATCH 5/5] fix: preserve existing session backups --- .../docker-git-session-sync/src/backup.ts | 7 ++ packages/docker-git-session-sync/src/shell.ts | 50 +++++++------- .../tests/session-files.test.ts | 66 ++++++------------- 3 files changed, 51 insertions(+), 72 deletions(-) diff --git a/packages/docker-git-session-sync/src/backup.ts b/packages/docker-git-session-sync/src/backup.ts index ad6bdeb3..d412e786 100644 --- a/packages/docker-git-session-sync/src/backup.ts +++ b/packages/docker-git-session-sync/src/backup.ts @@ -587,6 +587,13 @@ const runSessionUpload = ( const uploadEntries = [...prepared.uploadEntries, buildReadmeUploadEntry(readmeRepoPath, readmePath)] logVerbose(verbose, output, `Uploading snapshot to ${backupRepo.fullName}:${context.snapshotRef}`) const uploadResult = uploadSnapshot(backupRepo, context.snapshotRef, manifest, uploadEntries, ghEnv) + if (!uploadResult.changed) { + output.out(`[session-backup] skipped: no new or changed chat transcripts (${summary.fileCount} files, ${formatBytes(summary.totalBytes)})`) + printGitStatus(output, context.gitStatus) + logVerbose(verbose, output, `[session-backup] No backup repo changes for ${backupRepo.fullName}:${context.snapshotRef}`) + updateUploadComment(context, ghEnv, output, { state: "skipped", message: "No new or changed chat transcripts." }) + return 0 + } output.out(`[session-backup] ok: ${context.source.commitSha.slice(0, 12)} (${summary.fileCount} files, ${formatBytes(summary.totalBytes)})`) printGitStatus(output, context.gitStatus) logVerbose(verbose, output, `[session-backup] Uploaded snapshot to ${backupRepo.fullName}:${context.snapshotRef}`) diff --git a/packages/docker-git-session-sync/src/shell.ts b/packages/docker-git-session-sync/src/shell.ts index 23924772..712e2cc2 100644 --- a/packages/docker-git-session-sync/src/shell.ts +++ b/packages/docker-git-session-sync/src/shell.ts @@ -516,7 +516,7 @@ export const prepareUploadArtifacts = ( return { uploadEntries, manifestFiles } } -type GitTreeChange = { +export type GitTreeChange = { readonly path: string readonly mode: "100644" readonly type: "blob" @@ -533,25 +533,6 @@ const buildFileMapFromTreeEntries = (entries: ReadonlyArray): Map, - snapshotRef: string -): ReadonlyArray => { - const snapshotPrefix = `${snapshotRef}/` - return entries.filter((entry) => entry.path !== snapshotRef && !entry.path.startsWith(snapshotPrefix)) -} - -export const buildSnapshotDeleteTreeEntries = ( - entries: ReadonlyArray, - snapshotRef: string, - desiredPaths: ReadonlySet -): ReadonlyArray => { - const snapshotPrefix = `${snapshotRef}/` - return entries - .filter((entry) => entry.type !== "tree" && entry.path.startsWith(snapshotPrefix) && !desiredPaths.has(entry.path)) - .map((entry) => ({ path: entry.path, mode: "100644", type: "blob", sha: null })) -} - const createGitBlob = (repoFullName: string, entry: UploadEntry, ghEnv: GhEnv): string => { const content = fs.readFileSync(entry.sourcePath) const result = ensureSuccess( @@ -641,15 +622,13 @@ const updateGitRef = (repoFullName: string, branch: string, commitSha: string, g const isRefUpdateConflict = (result: CommandResult): boolean => /409|Conflict|Reference update failed|fast[- ]forward/iu.test(`${result.stderr}\n${result.stdout}`) -const buildUploadTreeChanges = ( +export const buildUploadTreeChanges = ( repoFullName: string, - snapshotRef: string, existingEntries: ReadonlyArray, desiredEntries: ReadonlyArray, ghEnv: GhEnv ): ReadonlyArray => { const existingFileMap = buildFileMapFromTreeEntries(existingEntries) - const desiredPaths = new Set(desiredEntries.map((entry) => entry.repoPath)) const changes: Array = [] for (const entry of desiredEntries) { if (existingFileMap.get(entry.repoPath)?.sha === entry.blobSha) { @@ -662,17 +641,27 @@ const buildUploadTreeChanges = ( sha: createGitBlob(repoFullName, entry, ghEnv) }) } - changes.push(...buildSnapshotDeleteTreeEntries(existingEntries, snapshotRef, desiredPaths)) return changes } +export const hasChangedUploadEntries = ( + existingEntries: ReadonlyArray, + desiredEntries: ReadonlyArray +): boolean => { + const existingFileMap = buildFileMapFromTreeEntries(existingEntries) + return desiredEntries.some((entry) => existingFileMap.get(entry.repoPath)?.sha !== entry.blobSha) +} + +const isContentUploadEntry = (entry: UploadEntry): boolean => + entry.type !== "readme" && entry.type !== "manifest" + export const uploadSnapshot = ( backupRepo: BackupRepo, snapshotRef: string, snapshotManifest: SnapshotManifest, uploadEntries: ReadonlyArray, ghEnv: GhEnv -): { readonly commitSha: string; readonly manifestPath: string; readonly manifestUrl: string } => { +): { readonly changed: boolean; readonly commitSha: string; readonly manifestPath: string; readonly manifestUrl: string } => { const uploadRoot = fs.mkdtempSync(path.join(os.tmpdir(), "session-backup-api-")) const manifestPath = `${snapshotRef}/manifest.json` const manifestTempPath = path.join(uploadRoot, "manifest.json") @@ -691,15 +680,23 @@ export const uploadSnapshot = ( if (currentTree.headSha === undefined) { throw new Error(`failed to resolve ${backupRepo.fullName}@${backupRepo.defaultBranch} head`) } + if (!hasChangedUploadEntries(currentTree.entries, uploadEntries.filter(isContentUploadEntry))) { + return { + changed: false, + commitSha: currentTree.headSha, + manifestPath, + manifestUrl: buildBlobUrl(backupRepo.fullName, backupRepo.defaultBranch, manifestPath) + } + } const changes = buildUploadTreeChanges( backupRepo.fullName, - snapshotRef, currentTree.entries, desiredEntries, ghEnv ) if (changes.length === 0) { return { + changed: false, commitSha: currentTree.headSha, manifestPath, manifestUrl: buildBlobUrl(backupRepo.fullName, backupRepo.defaultBranch, manifestPath) @@ -710,6 +707,7 @@ export const uploadSnapshot = ( const updateResult = updateGitRef(backupRepo.fullName, backupRepo.defaultBranch, commitSha, ghEnv) if (updateResult.success) { return { + changed: true, commitSha, manifestPath, manifestUrl: buildBlobUrl(backupRepo.fullName, backupRepo.defaultBranch, manifestPath) diff --git a/packages/docker-git-session-sync/tests/session-files.test.ts b/packages/docker-git-session-sync/tests/session-files.test.ts index 04f794a2..7855e0b9 100644 --- a/packages/docker-git-session-sync/tests/session-files.test.ts +++ b/packages/docker-git-session-sync/tests/session-files.test.ts @@ -13,12 +13,12 @@ import { import { collectSessionFiles, parseUploadContext, uploadFromContext, type Output } from "../src/backup.js" import { parseArgs } from "../src/cli.js" import { - buildSnapshotDeleteTreeEntries, + buildUploadTreeChanges, gitBlobShaForBuffer, - prepareUploadArtifacts, - removeSnapshotTreeEntries + hasChangedUploadEntries, + prepareUploadArtifacts } from "../src/shell.js" -import type { TreeEntry } from "../src/types.js" +import type { TreeEntry, UploadEntry } from "../src/types.js" const output: Output = { out: () => undefined, @@ -121,8 +121,8 @@ describe("upload artifacts", () => { }) }) -describe("snapshot tree replacement", () => { - it("removes only files under the exact current snapshot prefix", () => { +describe("snapshot tree updates", () => { + it("keeps stale remote session files untouched", () => { const entries: ReadonlyArray = [ { path: "org/repo/pr-230/current/.codex/sessions/old.jsonl", @@ -130,6 +130,12 @@ describe("snapshot tree replacement", () => { type: "blob", sha: "old" }, + { + path: "org/repo/pr-230/current/.codex/sessions/keep.jsonl", + mode: "100644", + type: "blob", + sha: "keep" + }, { path: "org/repo/pr-230/current-old/.codex/sessions/keep.jsonl", mode: "100644", @@ -149,50 +155,18 @@ describe("snapshot tree replacement", () => { sha: "keep-other-pr" } ] - - expect(removeSnapshotTreeEntries(entries, "org/repo/pr-230/current").map((entry) => entry.path)).toEqual([ - "org/repo/pr-230/current-old/.codex/sessions/keep.jsonl", - "org/repo/pr-230/2026-04-26/manifest.json", - "org/repo/pr-231/current/.codex/sessions/keep.jsonl" - ]) - }) - - it("builds delete entries only for stale current snapshot files", () => { - const entries: ReadonlyArray = [ + const desiredEntries: ReadonlyArray = [ { - path: "org/repo/pr-230/current/.codex/sessions/old.jsonl", - mode: "100644", - type: "blob", - sha: "old" - }, - { - path: "org/repo/pr-230/current/.codex/sessions/keep.jsonl", - mode: "100644", - type: "blob", - sha: "keep" - }, - { - path: "org/repo/pr-230/current-old/.codex/sessions/old.jsonl", - mode: "100644", - type: "blob", - sha: "neighbor" + repoPath: "org/repo/pr-230/current/.codex/sessions/keep.jsonl", + sourcePath: path.join(tmpDir, "unused.jsonl"), + type: "file", + size: 4, + blobSha: "keep" } ] - expect( - buildSnapshotDeleteTreeEntries( - entries, - "org/repo/pr-230/current", - new Set(["org/repo/pr-230/current/.codex/sessions/keep.jsonl"]) - ) - ).toEqual([ - { - path: "org/repo/pr-230/current/.codex/sessions/old.jsonl", - mode: "100644", - type: "blob", - sha: null - } - ]) + expect(hasChangedUploadEntries(entries, desiredEntries)).toBe(false) + expect(buildUploadTreeChanges("backup-owner/docker-git-sessions", entries, desiredEntries, {})).toEqual([]) }) })