Skip to content

feat(daemon): persist env workspace to deco/env/<name> branch#1185

Open
hugo-ccabral wants to merge 1 commit into
mainfrom
feat/env-state-on-git-branch
Open

feat(daemon): persist env workspace to deco/env/<name> branch#1185
hugo-ccabral wants to merge 1 commit into
mainfrom
feat/env-state-on-git-branch

Conversation

@hugo-ccabral

@hugo-ccabral hugo-ccabral commented May 11, 2026

Copy link
Copy Markdown
Contributor

Summary

Adds a git-backed persistence path for env pods so admin can drop the EBS PVC that today stores the cloned repo + WIP across restarts. Paired with deco-sites/admin#3160 which removes the PVC from the StatefulSet manifest.

When DECO_ENV_NAME is set:

  • ensureGit targets deco/env/<DECO_ENV_NAME>. It probes with ls-remote first; if the branch exists, clones it directly; otherwise clones the upstream branch and checkout -b's the env branch locally. Upstream is wired to origin/<upstream> so assertRebased rebases env state onto main on startup.
  • flushEnvBranch() commits + pushes the working tree to the env branch. Write-locked against publish/rebase/file-watch, skips when the tree is clean, refreshes the GitHub App token, never throws.
  • daemon/main.ts wires flushEnvBranch on a 2-min throttle in the file watcher, and into a SIGTERM/SIGINT handler capped at 60s so it cannot run past the pod's terminationGracePeriodSeconds.

Production pods (no DECO_ENV_NAME) are unaffected — every new code path is gated on ENV_BRANCH being set, and the existing SOURCE_ASSET_PATH tarball persistence continues to operate for post-publish snapshots.

Why

Env pods today rely on an EBS PVC to keep .git/ + the user's uncommitted edits across scale-to-zero cycles. EBS attach dominates cold-start (~30–60s), pins the pod to a specific AZ, and complicates pod scheduling. Pushing WIP to a side branch on the user's own repo lets us throw the disk away on shutdown and clone fresh on startup — same persistence outcome, no attach delay, no AZ pinning.

Test plan

  • Run a local env pod against a throwaway fork with DECO_ENV_NAME=test-1; verify the branch deco/env/test-1 appears on the fork after edits + restart.
  • Restart cycle: verify the env branch is rehydrated on cold start (clone visible in [step 1/4] log).
  • Push to upstream main while the env is running, scale env down + up, confirm env branch came back rebased onto the new upstream.
  • Force-kill the pod (kill -9) and verify the next cold start picks up the last successful periodic flush (loss bounded to ~2 min of edits).
  • Sandbox mode (no DECO_ENV_NAME): confirm unchanged behavior — no env branch, no flush, no SIGTERM handler installed.

🤖 Generated with Claude Code

Adds a git-backed persistence path for env pods so admin can drop the
EBS PVC that today stores the cloned repo + WIP across restarts.

When DECO_ENV_NAME is set:
- ensureGit targets `deco/env/<DECO_ENV_NAME>`; ls-remote-probes first
  and falls back to cloning the upstream branch + `checkout -b` when
  the env branch does not exist yet. Sets upstream to origin/<upstream>
  so assertRebased rebases env state onto main on startup.
- flushEnvBranch() commits + pushes the working tree to the env branch.
  Write-locked, skips on a clean tree, refreshes the GitHub App token,
  never throws (so a flaky network on shutdown can't crash the pod).
- main.ts wires flushEnvBranch on a 2-min throttle from the file watcher
  and into a SIGTERM/SIGINT handler capped at 60s so it cannot run past
  terminationGracePeriodSeconds.

Production pods (no DECO_ENV_NAME) are unaffected: the new code paths
are gated on ENV_BRANCH being set, and the existing SOURCE_ASSET_PATH
tarball path continues to operate for post-publish snapshots.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
@coderabbitai

coderabbitai Bot commented May 11, 2026

Copy link
Copy Markdown
📝 Walkthrough

Walkthrough

This PR introduces environment-specific Git branch support. When DECO_ENV_NAME is set, a derived ENV_BRANCH (deco/env/<name>) is probed and cloned during initialization. A new flushEnvBranch() function commits and pushes changes to this branch. The daemon integrates periodic and shutdown-triggered flushes to prevent WIP loss, replacing prior tarball persistence.

Changes

Environment Branch Feature

Layer / File(s) Summary
Environment Branch Constant
daemon/git.ts
Exports ENV_BRANCH constant computed from DECO_ENV_NAME in format deco/env/<name>.
Repository Bootstrap with Env Branch
daemon/git.ts
ensureGit now probes remote for ENV_BRANCH, clones it if available (otherwise clones upstream), creates local env branch, and configures upstream tracking to origin/<upstreamBranch>.
Branch Flushing Implementation
daemon/git.ts
New exported flushEnvBranch() acquires Git write lock, refreshes netrc credentials, commits pending changes, and pushes to origin/refs/heads/${ENV_BRANCH} without throwing on error.
Daemon Lifecycle Integration
daemon/main.ts
Imports env-branch helpers; adds SIGTERM handler that calls flushEnvBranch() with 60s timeout; introduces throttled flushEnvState() helper (2-min delay); wires filesystem watcher to use env-branch flush instead of tarball persistence.
Validation Error Message
daemon/git.ts
Reformats raw-git disallowed-subcommand error to display allowed subcommands across multiple lines.

Sequence Diagram

sequenceDiagram
  participant Git as Git Operations
  participant Init as ensureGit
  participant Remote as Remote (GitHub)
  participant Watch as FileWatcher
  participant Flush as flushEnvBranch
  participant Push as origin/refs/heads/ENV_BRANCH

  Init->>Remote: Probe ENV_BRANCH existence
  alt ENV_BRANCH exists
    Init->>Git: Clone ENV_BRANCH
  else
    Init->>Git: Clone upstream tracking branch
  end
  Init->>Git: Create local env branch + set upstream
  Init->>Git: Fetch origin/upstreamBranch

  loop Runtime
    Watch->>Watch: Detect file changes
    Watch->>Flush: Trigger flushEnvState (throttled)
    Flush->>Git: Check for working tree changes
    alt Changes exist
      Flush->>Git: Commit snapshot
      Flush->>Remote: Refresh netrc (GitHub App token)
      Flush->>Push: Push to origin/refs/heads/ENV_BRANCH
    end
  end

  Note over Flush: On SIGTERM (60s timeout)
  Flush->>Git: Final commit & push
  Flush->>Flush: Exit gracefully
Loading

Estimated code review effort

🎯 4 (Complex) | ⏱️ ~45 minutes

Possibly related PRs

  • deco-cx/deco#1112: Both PRs modify daemon/git.ts's ensureGit flow and GitHub netrc/token setup logic.
  • deco-cx/deco#1159: Both PRs modify daemon Git handling of GitHub credentials and netrc refresh.
  • deco-cx/deco#959: Both PRs add environment-driven branch selection to ensureGit cloning behavior based on DECO_ENV_NAME.

Suggested reviewers

  • nicacioliveira

Poem

🐰 A branch for each env, how neat!
Flushing changes on the beat,
No WIP lost when the daemon must flee,
GitHub credentials kept fresh as can be!
hop hop

🚥 Pre-merge checks | ✅ 5
✅ Passed checks (5 passed)
Check name Status Explanation
Title check ✅ Passed The title clearly and concisely summarizes the main feature: persisting env workspace to a Git branch (deco/env/), which is the primary objective of this changeset across both modified files.
Docstring Coverage ✅ Passed No functions found in the changed files to evaluate docstring coverage. Skipping docstring coverage check.
Linked Issues check ✅ Passed Check skipped because no linked issues were found for this pull request.
Out of Scope Changes check ✅ Passed Check skipped because no linked issues were found for this pull request.
Description Check ✅ Passed Check skipped - CodeRabbit’s high-level summary is enabled.

✏️ Tip: You can configure your own custom pre-merge checks in the settings.

✨ Finishing Touches
📝 Generate docstrings
  • Create stacked PR
  • Commit on current branch
🧪 Generate unit tests (beta)
  • Create PR with unit tests
  • Commit unit tests in branch feat/env-state-on-git-branch

Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out.

❤️ Share

Comment @coderabbitai help to get the list of available commands and usage tips.

@github-actions

Copy link
Copy Markdown
Contributor

Tagging Options

Should a new tag be published when this PR is merged?

  • 👍 for Patch 1.197.1 update
  • 🎉 for Minor 1.198.0 update
  • 🚀 for Major 2.0.0 update

@coderabbitai coderabbitai Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Actionable comments posted: 2

🧹 Nitpick comments (2)
daemon/main.ts (1)

167-197: 💤 Low value

Shutdown handler unconditionally Deno.exit(0) short-circuits the rest of the process.

A few small concerns worth a quick pass:

  1. Deno.exit(0) runs as soon as the flush settles (success, failure, or 60s timeout), so other in-flight work — outstanding HTTP requests on Deno.serve, the worker subprocess, file watchers, AI sessions — is hard-killed mid-flight. For env pods this is probably acceptable given k8s terminationGracePeriodSeconds, but it's worth confirming the worker subprocess gets its own SIGTERM from k8s (process groups) rather than being orphaned here.
  2. The exit code is always 0, even when the flush actually fails. If you want shutdown-flush failures to be visible in pod restart metrics / k8s lastTerminationState, prefer Deno.exit(err ? 1 : 0).
  3. Once flushing = true, a second SIGTERM is a no-op (if (flushing) return;). That means a stuck push past the 60s deadline can only be escaped by SIGKILL from k8s when the grace period elapses. That's the expected k8s contract, just noting it for confirmation.
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@daemon/main.ts` around lines 167 - 197, The shutdown handler currently always
calls Deno.exit(0) after attempting the flush in onShutdown, which prematurely
hard-kills other in-flight work and hides flush failures; update onShutdown to
(1) exit with a non-zero code when the flush rejects or times out (use
Deno.exit(1) on error/timeout, Deno.exit(0) only on successful flush), (2) treat
the timeout/rejection from the Promise.race as a failure so the exit code
reflects that, and (3) allow a second signal to force an immediate hard exit
(e.g., track a forceExit flag checked in the signal listeners that bypasses the
if (flushing) return; path) so stuck flushes can be escaped; refer to
onShutdown, flushing, SHUTDOWN_FLUSH_TIMEOUT_MS, and flushEnvBranch to implement
these changes.
daemon/git.ts (1)

920-952: ⚡ Quick win

flushEnvBranch holds the write lock across the network push.

lockerGitAPI.lock.wlock() is acquired for the whole function — including git.push to GitHub, which can take seconds-to-tens-of-seconds on a slow network or while waiting on setupGitHubAppNetrc. While it's held, publish, rebase, discard, checkoutBranch, checkoutExistingBranch, gitRaw, and status are all blocked (see app.use(lockerGitAPI.wlock) in createGitAPIS). With the file-watch throttle calling this every 2 min on a busy editor session, you'll see admin UI requests intermittently stalling on the daemon for the duration of a push.

Consider doing the staging/commit under the lock, then releasing the lock before the network push (or scoping the lock around git.status() + git.commit() only and pushing without holding it). At minimum, since the design assumes a single writer per env branch, the lock here is mostly serving to keep the index consistent — not to serialize pushes.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@daemon/git.ts` around lines 920 - 952, flushEnvBranch currently holds the
write lock (lockerGitAPI.lock.wlock()) across network operations; change it to
acquire the lock only for local index ops (call git.status(), then git.add() and
git.commit() while the lock is held) and then release the lock (dispose the
using _) before calling setupGithubTokenNetrc() and git.push(). Keep the
try/catch but split the workflow into a locked section that stages/commits and
an unlocked section that performs the network setup/push (referencing
flushEnvBranch, lockerGitAPI.lock.wlock(), git.status(), git.add(), commit(),
setupGithubTokenNetrc(), and git.push()) so pushes don’t block other writers.
🤖 Prompt for all review comments with AI agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

Inline comments:
In `@daemon/git.ts`:
- Around line 813-870: The clone+fetch flow unintentionally uses single-branch
shallow cloning when ENV_BRANCH is set (because --depth implies
--single-branch), so origin/<upstreamBranch> never becomes a remote-tracking ref
and branch --set-upstream-to fails; fix by making the git.clone call (the
git.clone(...) call that builds args with ENV_BRANCH and branchToClone) include
"--no-single-branch" when ENV_BRANCH is present so the wildcard refspec is
preserved, or alternatively change the subsequent git.fetch(...) (the
fetch(["origin", upstreamBranch, "--depth","1"]) call) to fetch an explicit
refspec that writes refs/remotes/origin/<upstreamBranch> (e.g.
refs/heads/<upstreamBranch>:refs/remotes/origin/<upstreamBranch>) so
branch([`--set-upstream-to=origin/${upstreamBranch}`, ENV_BRANCH]) and
assertRebased/status.tracking work correctly.
- Around line 942-948: The push to refs/heads/${ENV_BRANCH} can fail as
non-fast-forward after a rebase; update the git.push call in the flush/commit
flow (the call that currently invokes git.push(["origin",
`HEAD:refs/heads/${ENV_BRANCH}`])) to use force-with-lease so rebased local HEAD
can be applied while still protecting against concurrent remote writes (i.e.,
add the "--force-with-lease" refspec/option to the git.push invocation).

---

Nitpick comments:
In `@daemon/git.ts`:
- Around line 920-952: flushEnvBranch currently holds the write lock
(lockerGitAPI.lock.wlock()) across network operations; change it to acquire the
lock only for local index ops (call git.status(), then git.add() and
git.commit() while the lock is held) and then release the lock (dispose the
using _) before calling setupGithubTokenNetrc() and git.push(). Keep the
try/catch but split the workflow into a locked section that stages/commits and
an unlocked section that performs the network setup/push (referencing
flushEnvBranch, lockerGitAPI.lock.wlock(), git.status(), git.add(), commit(),
setupGithubTokenNetrc(), and git.push()) so pushes don’t block other writers.

In `@daemon/main.ts`:
- Around line 167-197: The shutdown handler currently always calls Deno.exit(0)
after attempting the flush in onShutdown, which prematurely hard-kills other
in-flight work and hides flush failures; update onShutdown to (1) exit with a
non-zero code when the flush rejects or times out (use Deno.exit(1) on
error/timeout, Deno.exit(0) only on successful flush), (2) treat the
timeout/rejection from the Promise.race as a failure so the exit code reflects
that, and (3) allow a second signal to force an immediate hard exit (e.g., track
a forceExit flag checked in the signal listeners that bypasses the if (flushing)
return; path) so stuck flushes can be escaped; refer to onShutdown, flushing,
SHUTDOWN_FLUSH_TIMEOUT_MS, and flushEnvBranch to implement these changes.
🪄 Autofix (Beta)

Fix all unresolved CodeRabbit comments on this PR:

  • Push a commit to this branch (recommended)
  • Create a new PR with the fixes

ℹ️ Review info
⚙️ Run configuration

Configuration used: defaults

Review profile: CHILL

Plan: Pro

Run ID: c7a9ada4-522e-4d2b-a934-fceaff47e37b

📥 Commits

Reviewing files that changed from the base of the PR and between 2666148 and 0312d9a.

📒 Files selected for processing (2)
  • daemon/git.ts
  • daemon/main.ts

Comment thread daemon/git.ts
Comment on lines +813 to +870
if (ENV_BRANCH) {
try {
const remoteRefs = await git.listRemote([
"--heads",
cloneUrl,
ENV_BRANCH,
]);
envBranchExists = remoteRefs.trim().length > 0;
} catch (err) {
console.warn(
`[ensureGit] ls-remote failed for ${ENV_BRANCH}, falling back to ${upstreamBranch}:`,
err,
);
}
}

const branchToClone = envBranchExists ? ENV_BRANCH! : upstreamBranch;

await git
.clone(cloneUrl, ".", [
"--depth",
"1",
"--single-branch",
// In env mode we also want refs for upstreamBranch so assertRebased
// can rebase onto it; --single-branch would restrict refspec to the
// checked-out branch only.
...(ENV_BRANCH ? [] : ["--single-branch"]),
"--branch",
branch ?? DEFAULT_TRACKING_BRANCH,
branchToClone,
])
.submoduleInit()
.submoduleUpdate(["--depth", "1"]);

if (ENV_BRANCH) {
if (!envBranchExists) {
// Cloned upstream — create the env branch locally. First push will
// create it on origin via flushEnvBranch.
await git.checkoutLocalBranch(ENV_BRANCH);
}
// Make sure origin/<upstream> is fetched so assertRebased has a target.
await git
.fetch(["origin", upstreamBranch, "--depth", "1"])
.catch((err) => {
console.warn(
`[ensureGit] fetch origin ${upstreamBranch} failed:`,
err,
);
});
// Point the env branch's upstream at origin/<upstream> so status.tracking
// resolves there and assertRebased rebases env state onto upstream.
await git
.branch([`--set-upstream-to=origin/${upstreamBranch}`, ENV_BRANCH])
.catch((err) => {
console.warn(
`[ensureGit] set-upstream-to origin/${upstreamBranch} for ${ENV_BRANCH} failed:`,
err,
);
});
}

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🔴 Critical | ⚡ Quick win

🧩 Analysis chain

🌐 Web query:

git clone --depth 1 --branch X behavior of remote.origin.fetch refspec narrowing

💡 Result:

When you run git clone --depth 1 --branch X <repo>, Git creates a shallow clone that is also effectively a single-branch clone (unless you explicitly disable it). That single-branch behavior narrows the remote fetch refspec (remote.origin.fetch) so that future git fetch operations only update the remote-tracking ref for branch X instead of all branch heads. Evidence-backed behavior: 1) --depth implies --single-branch by default, and the man page explicitly states that further fetches “will only update the remote-tracking branch for the branch this option was used for” [1][2]. 2) In practice, shallow+single-branch results in remote.origin.fetch being set to a branch-specific refspec like +refs/heads/<branch>:refs/remotes/origin/<branch> rather than the default wildcard refspec +refs/heads/*:refs/remotes/origin/* [3]. 3) If you want to fetch other branches from the same shallow clone, you must widen the refspec back to the wildcard form and then fetch again, or do a targeted fetch with an explicit refspec [3]. Example of what you should expect in .git/config after such a clone: - Single-branch/shallow style (narrowed): fetch = +refs/heads/X:refs/remotes/origin/X [3] - Default full fetch style (not narrowed): fetch = +refs/heads/*:refs/remotes/origin/* [3] How to “fix”/override the narrowing: - Widen the refspec, then fetch: git config remote.origin.fetch '+refs/heads/*:refs/remotes/origin/*' [3] git fetch [3] - Or fetch just the missing branch once with an explicit refspec (depth-limited if desired): git fetch --depth=1 origin Y:Y or git fetch --depth=1 origin Y:refs/remotes/origin/Y [4] So, the key point about “refspec narrowing” is: --depth 1 triggers single-branch mode, and single-branch mode narrows the origin fetch refspec to only the chosen branch X, which then controls what git fetch updates going forward [1][2][3].

Citations:


🏁 Script executed:

# First, let's understand the structure and locate the relevant code
git ls-files daemon/ | head -20

Repository: deco-cx/deco

Length of output: 415


🏁 Script executed:

# Look at the git.ts file to understand the git wrapper methods
wc -l daemon/git.ts

Repository: deco-cx/deco

Length of output: 75


🏁 Script executed:

# Examine the exact code in question with context
sed -n '813,870p' daemon/git.ts | cat -n

Repository: deco-cx/deco

Length of output: 2359


🏁 Script executed:

# Find the git method implementations (clone, fetch, branch, etc.)
rg -A 10 "\.clone\(" daemon/git.ts | head -40

Repository: deco-cx/deco

Length of output: 435


🏁 Script executed:

# Look for assertRebased implementation
rg -n "assertRebased" daemon/git.ts
rg -n "DEFAULT_TRACKING_BRANCH" daemon/git.ts

Repository: deco-cx/deco

Length of output: 726


🏁 Script executed:

# Check the git wrapper class to understand how fetch and branch work
rg -B 5 -A 15 "fetch\(" daemon/git.ts | head -60

Repository: deco-cx/deco

Length of output: 1409


🏁 Script executed:

# Read the assertRebased function implementation
sed -n '638,700p' daemon/git.ts | cat -n

Repository: deco-cx/deco

Length of output: 2231


🏁 Script executed:

# Read more context around git class/methods
sed -n '1,100p' daemon/git.ts | cat -n

Repository: deco-cx/deco

Length of output: 3940


🏁 Script executed:

# Look for the git command wrapper - likely a class or factory
rg -B 2 -A 20 "class.*Git|function.*git|const git =" daemon/git.ts | head -80

Repository: deco-cx/deco

Length of output: 720


🏁 Script executed:

# Check how fetch is actually implemented - what command is constructed
rg -B 5 -A 20 "fetch.*function|fetch.*=" daemon/git.ts | head -100

Repository: deco-cx/deco

Length of output: 613


🏁 Script executed:

# Look at git status and how tracking is read
sed -n '667,680p' daemon/git.ts | cat -n

Repository: deco-cx/deco

Length of output: 518


🏁 Script executed:

# Check how simple-git is used and what it exports
rg -A 5 "from \"simple-git\"" daemon/git.ts

Repository: deco-cx/deco

Length of output: 333


🏁 Script executed:

# Look at the exact git.clone call to understand how simple-git constructs the command
sed -n '830,875p' daemon/git.ts | cat -n

Repository: deco-cx/deco

Length of output: 2008


🏁 Script executed:

# Check if there are any integration tests or logs showing actual git behavior
find . -name "*.test.ts" -o -name "*.spec.ts" | xargs grep -l "clone\|ENV_BRANCH" 2>/dev/null | head -5

Repository: deco-cx/deco

Length of output: 82


🏁 Script executed:

# Search for any documentation or comments about simple-git behavior
rg -B 3 -A 3 "simple-git" daemon/

Repository: deco-cx/deco

Length of output: 1044


🏁 Script executed:

# Check the exact git raw command usage in assertRebased to see what tracking value would be used
sed -n '638,680p' daemon/git.ts | cat -n

Repository: deco-cx/deco

Length of output: 1577


Pass --no-single-branch in env mode to preserve multi-branch refspec in shallow clones.

The comment claiming omitted --single-branch keeps a wildcard refspec is incorrect: git's --depth=<n> implicitly enables --single-branch unless --no-single-branch is explicitly passed. The current code (lines 830–841) omits both flags in env mode, so the clone uses only --depth 1 with single-branch enabled by default, resulting in a narrowed .git/config refspec (+refs/heads/<branchToClone>:refs/remotes/origin/<branchToClone>) rather than the intended wildcard.

Consequences:

  • The git fetch origin upstreamBranch --depth 1 on line 853 does not create refs/remotes/origin/<upstreamBranch>; it only updates FETCH_HEAD
  • The git branch --set-upstream-to=origin/<upstreamBranch> on line 863 fails silently (caught by .catch) because the remote-tracking ref doesn't exist
  • assertRebased reads status.tracking as empty on line 667, falls back to DEFAULT_TRACKING_BRANCH ("main"), and the subsequent git rev-list --count <base>..main fails or produces wrong results

Fix: Add --no-single-branch when ENV_BRANCH is set. Alternatively, use an explicit refspec on the fetch to force creation of the remote-tracking ref.

Suggested fix
     await git
       .clone(cloneUrl, ".", [
         "--depth",
         "1",
-        // In env mode we also want refs for upstreamBranch so assertRebased
-        // can rebase onto it; --single-branch would restrict refspec to the
-        // checked-out branch only.
-        ...(ENV_BRANCH ? [] : ["--single-branch"]),
+        // In env mode we also want refs for upstreamBranch so assertRebased
+        // can rebase onto it. Note: --depth implies --single-branch unless
+        // --no-single-branch is given, so we must set it explicitly.
+        ...(ENV_BRANCH ? ["--no-single-branch"] : ["--single-branch"]),
         "--branch",
         branchToClone,
       ])

Or keep single-branch behavior and use explicit refspec on fetch:

       await git
-        .fetch(["origin", upstreamBranch, "--depth", "1"])
+        .fetch([
+          "origin",
+          `+refs/heads/${upstreamBranch}:refs/remotes/origin/${upstreamBranch}`,
+          "--depth",
+          "1",
+        ])
📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
if (ENV_BRANCH) {
try {
const remoteRefs = await git.listRemote([
"--heads",
cloneUrl,
ENV_BRANCH,
]);
envBranchExists = remoteRefs.trim().length > 0;
} catch (err) {
console.warn(
`[ensureGit] ls-remote failed for ${ENV_BRANCH}, falling back to ${upstreamBranch}:`,
err,
);
}
}
const branchToClone = envBranchExists ? ENV_BRANCH! : upstreamBranch;
await git
.clone(cloneUrl, ".", [
"--depth",
"1",
"--single-branch",
// In env mode we also want refs for upstreamBranch so assertRebased
// can rebase onto it; --single-branch would restrict refspec to the
// checked-out branch only.
...(ENV_BRANCH ? [] : ["--single-branch"]),
"--branch",
branch ?? DEFAULT_TRACKING_BRANCH,
branchToClone,
])
.submoduleInit()
.submoduleUpdate(["--depth", "1"]);
if (ENV_BRANCH) {
if (!envBranchExists) {
// Cloned upstream — create the env branch locally. First push will
// create it on origin via flushEnvBranch.
await git.checkoutLocalBranch(ENV_BRANCH);
}
// Make sure origin/<upstream> is fetched so assertRebased has a target.
await git
.fetch(["origin", upstreamBranch, "--depth", "1"])
.catch((err) => {
console.warn(
`[ensureGit] fetch origin ${upstreamBranch} failed:`,
err,
);
});
// Point the env branch's upstream at origin/<upstream> so status.tracking
// resolves there and assertRebased rebases env state onto upstream.
await git
.branch([`--set-upstream-to=origin/${upstreamBranch}`, ENV_BRANCH])
.catch((err) => {
console.warn(
`[ensureGit] set-upstream-to origin/${upstreamBranch} for ${ENV_BRANCH} failed:`,
err,
);
});
}
await git
.clone(cloneUrl, ".", [
"--depth",
"1",
// In env mode we also want refs for upstreamBranch so assertRebased
// can rebase onto it. Note: --depth implies --single-branch unless
// --no-single-branch is given, so we must set it explicitly.
...(ENV_BRANCH ? ["--no-single-branch"] : ["--single-branch"]),
"--branch",
branchToClone,
])
.submoduleInit()
.submoduleUpdate(["--depth", "1"]);
if (ENV_BRANCH) {
if (!envBranchExists) {
// Cloned upstream — create the env branch locally. First push will
// create it on origin via flushEnvBranch.
await git.checkoutLocalBranch(ENV_BRANCH);
}
// Make sure origin/<upstream> is fetched so assertRebased has a target.
await git
.fetch(["origin", upstreamBranch, "--depth", "1"])
.catch((err) => {
console.warn(
`[ensureGit] fetch origin ${upstreamBranch} failed:`,
err,
);
});
// Point the env branch's upstream at origin/<upstream> so status.tracking
// resolves there and assertRebased rebases env state onto upstream.
await git
.branch([`--set-upstream-to=origin/${upstreamBranch}`, ENV_BRANCH])
.catch((err) => {
console.warn(
`[ensureGit] set-upstream-to origin/${upstreamBranch} for ${ENV_BRANCH} failed:`,
err,
);
});
}
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@daemon/git.ts` around lines 813 - 870, The clone+fetch flow unintentionally
uses single-branch shallow cloning when ENV_BRANCH is set (because --depth
implies --single-branch), so origin/<upstreamBranch> never becomes a
remote-tracking ref and branch --set-upstream-to fails; fix by making the
git.clone call (the git.clone(...) call that builds args with ENV_BRANCH and
branchToClone) include "--no-single-branch" when ENV_BRANCH is present so the
wildcard refspec is preserved, or alternatively change the subsequent
git.fetch(...) (the fetch(["origin", upstreamBranch, "--depth","1"]) call) to
fetch an explicit refspec that writes refs/remotes/origin/<upstreamBranch> (e.g.
refs/heads/<upstreamBranch>:refs/remotes/origin/<upstreamBranch>) so
branch([`--set-upstream-to=origin/${upstreamBranch}`, ENV_BRANCH]) and
assertRebased/status.tracking work correctly.

Comment thread daemon/git.ts
Comment on lines +942 to +948
await git
.add(["-A"])
.commit(message, { "--no-verify": null });
// Explicit refspec: the upstream is origin/<DEFAULT_TRACKING_BRANCH> so a
// bare `git push` would push env state onto upstream. We push to the env
// branch by name instead.
await git.push(["origin", `HEAD:refs/heads/${ENV_BRANCH}`]);

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🔴 Critical | ⚡ Quick win

🧩 Analysis chain

🏁 Script executed:

# First, locate the file and understand the context
find . -name "git.ts" -path "*/daemon/*" | head -5

Repository: deco-cx/deco

Length of output: 72


🏁 Script executed:

# Get file size to determine how much we can read
wc -l ./daemon/git.ts

Repository: deco-cx/deco

Length of output: 77


🏁 Script executed:

# Read the flushEnvBranch function context around lines 942-948
sed -n '920,970p' ./daemon/git.ts

Repository: deco-cx/deco

Length of output: 1545


🏁 Script executed:

# Search for ensureGit and assertRebased functions
rg -n "export const ensureGit|const ensureGit|function ensureGit" ./daemon/git.ts

Repository: deco-cx/deco

Length of output: 94


🏁 Script executed:

# Read ensureGit function - read a larger range to get the complete function
sed -n '716,820p' ./daemon/git.ts

Repository: deco-cx/deco

Length of output: 3565


🏁 Script executed:

# Continue reading ensureGit to find the clone command
sed -n '820,900p' ./daemon/git.ts

Repository: deco-cx/deco

Length of output: 2780


🏁 Script executed:

# Search for assertRebased function
rg -n "assertRebased" ./daemon/git.ts | head -20

Repository: deco-cx/deco

Length of output: 392


🏁 Script executed:

# Read the assertRebased function
sed -n '638,715p' ./daemon/git.ts

Repository: deco-cx/deco

Length of output: 2248


🏁 Script executed:

# Check the catch block and error handling around flushEnvBranch
sed -n '945,955p' ./daemon/git.ts

Repository: deco-cx/deco

Length of output: 460


🏁 Script executed:

# Search for ENV_BRANCH setup and any test references to rebase behavior
rg -n "ENV_BRANCH" ./daemon/git.ts | head -20

Repository: deco-cx/deco

Length of output: 826


🏁 Script executed:

# Look for any tests or comments about the rebase+push scenario
rg -n "rebase|rebased|rebase-then-flush" ./daemon/git.ts

Repository: deco-cx/deco

Length of output: 1766


🏁 Script executed:

# Search for test files related to git functionality
find . -name "*.test.ts" -o -name "*.spec.ts" | grep -i git | head -10

Repository: deco-cx/deco

Length of output: 38


🏁 Script executed:

# Search for any force-with-lease or push-related logic
rg -n "force-with-lease|force|--force" ./daemon/git.ts

Repository: deco-cx/deco

Length of output: 73


🏁 Script executed:

# Check context around line 389
sed -n '380,400p' ./daemon/git.ts

Repository: deco-cx/deco

Length of output: 484


🏁 Script executed:

# Check if git.push is a wrapper that calls validateRawArgs
rg -n "git.push|\.push\(" ./daemon/git.ts | head -20

Repository: deco-cx/deco

Length of output: 391


Push to refs/heads/${ENV_BRANCH} will fail as non-fast-forward after a rebase.

When ensureGit clones with --depth 1, the remote-tracking branch origin/ENV_BRANCH points at that shallow commit. When assertRebased rebases the local ENV_BRANCH onto origin/<upstreamBranch>, it creates new commits with a different parent chain. The remote-tracking origin/ENV_BRANCH is never updated by the rebase (only by fetch), so when flushEnvBranch attempts to push, HEAD is not a descendant of origin/ENV_BRANCH—the push fails as non-fast-forward. The error is caught and logged silently at line 949, so env state never persists. This breaks the rebase-then-flush cycle.

Add --force-with-lease to allow the push while still rejecting concurrent writes by other pods:

🛠️ Suggested fix
-    await git.push(["origin", `HEAD:refs/heads/${ENV_BRANCH}`]);
+    // Local ENV_BRANCH may have been rebased onto origin/<upstreamBranch>
+    // by assertRebased, so it's not a descendant of origin/ENV_BRANCH.
+    // Use --force-with-lease so we still bail if another pod (mis)wrote to
+    // the same DECO_ENV_NAME branch concurrently.
+    await git.push([
+      "origin",
+      `HEAD:refs/heads/${ENV_BRANCH}`,
+      "--force-with-lease",
+    ]);
📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
await git
.add(["-A"])
.commit(message, { "--no-verify": null });
// Explicit refspec: the upstream is origin/<DEFAULT_TRACKING_BRANCH> so a
// bare `git push` would push env state onto upstream. We push to the env
// branch by name instead.
await git.push(["origin", `HEAD:refs/heads/${ENV_BRANCH}`]);
await git
.add(["-A"])
.commit(message, { "--no-verify": null });
// Explicit refspec: the upstream is origin/<DEFAULT_TRACKING_BRANCH> so a
// bare `git push` would push env state onto upstream. We push to the env
// branch by name instead.
// Local ENV_BRANCH may have been rebased onto origin/<upstreamBranch>
// by assertRebased, so it's not a descendant of origin/ENV_BRANCH.
// Use --force-with-lease so we still bail if another pod (mis)wrote to
// the same DECO_ENV_NAME branch concurrently.
await git.push([
"origin",
`HEAD:refs/heads/${ENV_BRANCH}`,
"--force-with-lease",
]);
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@daemon/git.ts` around lines 942 - 948, The push to refs/heads/${ENV_BRANCH}
can fail as non-fast-forward after a rebase; update the git.push call in the
flush/commit flow (the call that currently invokes git.push(["origin",
`HEAD:refs/heads/${ENV_BRANCH}`])) to use force-with-lease so rebased local HEAD
can be applied while still protecting against concurrent remote writes (i.e.,
add the "--force-with-lease" refspec/option to the git.push invocation).

@cubic-dev-ai cubic-dev-ai Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

3 issues found across 2 files

Prompt for AI agents (unresolved issues)

Check if these issues are valid — if so, understand the root cause of each and fix them. If appropriate, use sub-agents to investigate and fix each issue separately.


<file name="daemon/git.ts">

<violation number="1" location="daemon/git.ts:838">
P1: `--depth 1` implicitly enables `--single-branch` unless `--no-single-branch` is explicitly passed (per git-clone docs). Omitting `--single-branch` here has no effect — the clone still narrows the refspec to only `branchToClone`. As a result, the subsequent `fetch origin ${upstreamBranch}` only writes to `FETCH_HEAD` (not `refs/remotes/origin/${upstreamBranch}`), the `--set-upstream-to` on line 863 fails silently because the remote-tracking ref doesn't exist, and `assertRebased` can't resolve its rebase target.

Pass `--no-single-branch` in env mode, or use an explicit refspec on the fetch (`+refs/heads/${upstreamBranch}:refs/remotes/origin/${upstreamBranch}`).</violation>

<violation number="2" location="daemon/git.ts:936">
P2: If push fails after a successful commit, subsequent `flushEnvBranch` calls (including the SIGTERM handler) see a clean working tree and return early — the locally committed snapshot is never pushed. Consider also checking for unpushed commits (e.g., whether HEAD is ahead of the remote ref) or retrying the push separately from the commit.</violation>

<violation number="3" location="daemon/git.ts:948">
P1: After `assertRebased` rebases the env branch onto upstream, the local history diverges from `origin/${ENV_BRANCH}` — a plain push is rejected as non-fast-forward. The error is caught and logged, so env state silently never persists after a rebase cycle.

Use `--force-with-lease` to allow the diverged push while still detecting concurrent writes from another pod.</violation>
</file>

Reply with feedback, questions, or to request a fix. Tag @cubic-dev-ai to re-run a review.

Comment thread daemon/git.ts
// Explicit refspec: the upstream is origin/<DEFAULT_TRACKING_BRANCH> so a
// bare `git push` would push env state onto upstream. We push to the env
// branch by name instead.
await git.push(["origin", `HEAD:refs/heads/${ENV_BRANCH}`]);

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P1: After assertRebased rebases the env branch onto upstream, the local history diverges from origin/${ENV_BRANCH} — a plain push is rejected as non-fast-forward. The error is caught and logged, so env state silently never persists after a rebase cycle.

Use --force-with-lease to allow the diverged push while still detecting concurrent writes from another pod.

Prompt for AI agents
Check if this issue is valid — if so, understand the root cause and fix it. At daemon/git.ts, line 948:

<comment>After `assertRebased` rebases the env branch onto upstream, the local history diverges from `origin/${ENV_BRANCH}` — a plain push is rejected as non-fast-forward. The error is caught and logged, so env state silently never persists after a rebase cycle.

Use `--force-with-lease` to allow the diverged push while still detecting concurrent writes from another pod.</comment>

<file context>
@@ -842,6 +906,51 @@ export const ensureGit = async ({
+    // Explicit refspec: the upstream is origin/<DEFAULT_TRACKING_BRANCH> so a
+    // bare `git push` would push env state onto upstream. We push to the env
+    // branch by name instead.
+    await git.push(["origin", `HEAD:refs/heads/${ENV_BRANCH}`]);
+  } catch (err) {
+    console.error(`[flushEnvBranch] Failed to flush ${ENV_BRANCH}:`, err);
</file context>
Suggested change
await git.push(["origin", `HEAD:refs/heads/${ENV_BRANCH}`]);
// After assertRebased, the local env branch is rebased onto upstream and
// is no longer a descendant of origin/ENV_BRANCH. --force-with-lease
// allows the push while still rejecting concurrent writes.
await git.push(["origin", `HEAD:refs/heads/${ENV_BRANCH}`, "--force-with-lease"]);

Comment thread daemon/git.ts
// In env mode we also want refs for upstreamBranch so assertRebased
// can rebase onto it; --single-branch would restrict refspec to the
// checked-out branch only.
...(ENV_BRANCH ? [] : ["--single-branch"]),

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P1: --depth 1 implicitly enables --single-branch unless --no-single-branch is explicitly passed (per git-clone docs). Omitting --single-branch here has no effect — the clone still narrows the refspec to only branchToClone. As a result, the subsequent fetch origin ${upstreamBranch} only writes to FETCH_HEAD (not refs/remotes/origin/${upstreamBranch}), the --set-upstream-to on line 863 fails silently because the remote-tracking ref doesn't exist, and assertRebased can't resolve its rebase target.

Pass --no-single-branch in env mode, or use an explicit refspec on the fetch (+refs/heads/${upstreamBranch}:refs/remotes/origin/${upstreamBranch}).

Prompt for AI agents
Check if this issue is valid — if so, understand the root cause and fix it. At daemon/git.ts, line 838:

<comment>`--depth 1` implicitly enables `--single-branch` unless `--no-single-branch` is explicitly passed (per git-clone docs). Omitting `--single-branch` here has no effect — the clone still narrows the refspec to only `branchToClone`. As a result, the subsequent `fetch origin ${upstreamBranch}` only writes to `FETCH_HEAD` (not `refs/remotes/origin/${upstreamBranch}`), the `--set-upstream-to` on line 863 fails silently because the remote-tracking ref doesn't exist, and `assertRebased` can't resolve its rebase target.

Pass `--no-single-branch` in env mode, or use an explicit refspec on the fetch (`+refs/heads/${upstreamBranch}:refs/remotes/origin/${upstreamBranch}`).</comment>

<file context>
@@ -794,17 +803,72 @@ export const ensureGit = async ({
+        // In env mode we also want refs for upstreamBranch so assertRebased
+        // can rebase onto it; --single-branch would restrict refspec to the
+        // checked-out branch only.
+        ...(ENV_BRANCH ? [] : ["--single-branch"]),
         "--branch",
-        branch ?? DEFAULT_TRACKING_BRANCH,
</file context>
Suggested change
...(ENV_BRANCH ? [] : ["--single-branch"]),
// --depth implies --single-branch; we must explicitly opt out in env
// mode so the upstream branch remote-tracking ref is available for
// assertRebased.
...(ENV_BRANCH ? ["--no-single-branch"] : ["--single-branch"]),

Comment thread daemon/git.ts
await setupGithubTokenNetrc(repoOverride);
}

const status = await git.status();

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2: If push fails after a successful commit, subsequent flushEnvBranch calls (including the SIGTERM handler) see a clean working tree and return early — the locally committed snapshot is never pushed. Consider also checking for unpushed commits (e.g., whether HEAD is ahead of the remote ref) or retrying the push separately from the commit.

Prompt for AI agents
Check if this issue is valid — if so, understand the root cause and fix it. At daemon/git.ts, line 936:

<comment>If push fails after a successful commit, subsequent `flushEnvBranch` calls (including the SIGTERM handler) see a clean working tree and return early — the locally committed snapshot is never pushed. Consider also checking for unpushed commits (e.g., whether HEAD is ahead of the remote ref) or retrying the push separately from the commit.</comment>

<file context>
@@ -842,6 +906,51 @@ export const ensureGit = async ({
+      await setupGithubTokenNetrc(repoOverride);
+    }
+
+    const status = await git.status();
+    if (status.files.length === 0) {
+      return;
</file context>

Comment thread daemon/git.ts
@@ -794,17 +803,72 @@ export const ensureGit = async ({
? `https://github.com/deco-sites/${site}.git`
: `git@github.com:deco-sites/${site}.git`);

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Colocar a ORG do github como um parâmetro para não ficar hardcoded

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants