Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
115 changes: 112 additions & 3 deletions daemon/git.ts
Original file line number Diff line number Diff line change
Expand Up @@ -21,6 +21,13 @@ const GITHUB_APP_KEY = Deno.env.get("GITHUB_APP_KEY");
const BUILD_FILES_DIR = Deno.env.get("BUILD_FILES_DIR");
const ADMIN_DOMAIN = "https://admin.deco.cx";

const envBranchName = (): string | undefined => {
const envName = Deno.env.get("DECO_ENV_NAME");
return envName ? `deco/env/${envName}` : undefined;
};

export const ENV_BRANCH = envBranchName();

/**
* Parse owner and repo name from a GitHub URL.
* Handles HTTPS (https://github.com/owner/repo.git) and
Expand Down Expand Up @@ -392,7 +399,9 @@ const validateRawArgs = (args: string[]): string | null => {

const subcommand = args[0];
if (!ALLOWED_SUBCOMMANDS.has(subcommand)) {
return `Subcommand "${subcommand}" is not allowed. Allowed: ${[...ALLOWED_SUBCOMMANDS].join(", ")}`;
return `Subcommand "${subcommand}" is not allowed. Allowed: ${
[...ALLOWED_SUBCOMMANDS].join(", ")
}`;
}

const blockedFlag = args.find((arg) => {
Expand Down Expand Up @@ -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


const upstreamBranch = branch ?? DEFAULT_TRACKING_BRANCH;

// When DECO_ENV_NAME is set, each env pod tracks its own branch
// (deco/env/<name>) and uses the upstream branch (main) as the rebase
// target. The branch may not exist yet on first boot, so we probe with
// ls-remote and fall back to cloning the upstream + creating it locally.
let envBranchExists = false;
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"]),

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"]),

"--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,
);
});
}
Comment on lines +813 to +870

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.


// Exclude AI agent artifacts from git tracking
const excludeFile = join(Deno.cwd(), ".git/info/exclude");
try {
Expand Down Expand Up @@ -842,6 +906,51 @@ export const ensureGit = async ({
}
};

/**
* Commits the current working tree and pushes it to `deco/env/<DECO_ENV_NAME>`
* on origin. Used to persist env-pod state across restarts in place of an EBS
* PVC. No-op when DECO_ENV_NAME is unset (production pods, sandbox mode).
*
* The function:
* - refreshes the GitHub App netrc token so push auth is fresh,
* - skips if the working tree is clean (no commit, no network),
* - acquires the write lock so it doesn't race with publish/rebase/file-watch,
* - never throws (failures are logged so a flaky push doesn't crash shutdown).
*/
export const flushEnvBranch = async (): Promise<void> => {
if (!ENV_BRANCH) return;

using _ = await lockerGitAPI.lock.wlock();

try {
if (GITHUB_APP_CONFIGURED || GITHUB_APP_KEY) {
const remoteUrl = await git.remote(["get-url", "origin"]).catch(
() => undefined,
);
const repoOverride = remoteUrl && isGitHubUrl(remoteUrl)
? parseGitHubOwnerRepo(remoteUrl) ?? undefined
: undefined;
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>

if (status.files.length === 0) {
return;
}

const message = `auto: env snapshot ${new Date().toISOString()}`;
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}`]);
Comment on lines +942 to +948

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).

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"]);

} catch (err) {
console.error(`[flushEnvBranch] Failed to flush ${ENV_BRANCH}:`, err);
}
};

interface Options {
build: Deno.Command | null;
site: string;
Expand Down
54 changes: 53 additions & 1 deletion daemon/main.ts
Original file line number Diff line number Diff line change
Expand Up @@ -23,7 +23,13 @@ import {
setSiteName,
} from "./daemon.ts";
import { watchFS } from "./fs/api.ts";
import { ensureGit, getGitHubPackageTokens, lockerGitAPI } from "./git.ts";
import {
ensureGit,
ENV_BRANCH,
flushEnvBranch,
getGitHubPackageTokens,
lockerGitAPI,
} from "./git.ts";
import { logs } from "./loggings/stream.ts";
import { watchMeta } from "./meta.ts";
import {
Expand Down Expand Up @@ -155,6 +161,41 @@ globalThis.addEventListener(
},
);

// SIGTERM flush: commit + push any WIP to deco/env/<name> before exit so the
// next pod cold-starts from the latest state. Capped with a hard timeout so a
// hung push can't keep us past terminationGracePeriodSeconds.
if (ENV_BRANCH) {
const SHUTDOWN_FLUSH_TIMEOUT_MS = 60_000;
let flushing = false;
const onShutdown = async (signal: string) => {
if (flushing) return;
flushing = true;
console.log(`[shutdown] received ${signal}, flushing ${ENV_BRANCH}`);
try {
await Promise.race([
flushEnvBranch(),
new Promise<void>((_, reject) =>
setTimeout(
() => reject(new Error("shutdown flush timed out")),
SHUTDOWN_FLUSH_TIMEOUT_MS,
)
),
]);
console.log(`[shutdown] flush complete`);
} catch (err) {
console.error(`[shutdown] flush failed:`, err);
}
Deno.exit(0);
};
Deno.addSignalListener("SIGTERM", () => onShutdown("SIGTERM"));
try {
// SIGINT is supported on Unix; the call throws on platforms where it isn't.
Deno.addSignalListener("SIGINT", () => onShutdown("SIGINT"));
} catch {
// Ignore — SIGINT not available on this platform.
}
}

const createBundler = (appName?: string) => {
const bundler = bundleApp(Deno.cwd());

Expand Down Expand Up @@ -211,6 +252,14 @@ const persistState = throttle(async () => {
await Promise.all([persist(), delay(2 * 60 * 1_000)]);
});

// When running as an env pod (DECO_ENV_NAME set), the workspace is persisted
// by pushing to the deco/env/<name> branch on origin instead of via the
// SOURCE_ASSET_PATH tarball. Throttled to one flush per 2 min so file-watch
// chatter doesn't spam git pushes.
const flushEnvState = throttle(async () => {
await Promise.all([flushEnvBranch(), delay(2 * 60 * 1_000)]);
});

// Watch for changes in filesystem
// TODO: we should be able to completely remove this after in some point in the future
const watch = async (signal?: AbortSignal) => {
Expand Down Expand Up @@ -262,6 +311,9 @@ const watch = async (signal?: AbortSignal) => {

// TODO: We should be able to remove this after we migrate to ebs
persistState();

// Env-pod state lives on the deco/env/<name> branch on origin.
flushEnvState();
}
};

Expand Down
Loading