Skip to content
Draft
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
87 changes: 80 additions & 7 deletions src/git.ts
Original file line number Diff line number Diff line change
Expand Up @@ -176,25 +176,98 @@ export function cloneRepo(options: CloneRepoOptions): string {
// Commit and Push
// =============================================================================

/**
* Runs a git command capturing stdout/stderr. On failure, throws an Error whose
* message includes git's combined output so the real reason (e.g. a rejected
* push) is preserved rather than the bare "Command failed: git ..." string.
*/
function git(args: string[], repoLocation: string): string {
try {
return execFileSync("git", args, {
cwd: repoLocation,
encoding: "utf-8",
stdio: ["pipe", "pipe", "pipe"],
});
} catch (error) {
throw new Error(`git ${args.join(" ")} failed: ${gitErrorOutput(error)}`);
}
}

/** Extracts git's combined stdout/stderr (falling back to the error message) from a thrown exec error. */
function gitErrorOutput(error: unknown): string {
const e = error as { stderr?: string | Buffer; stdout?: string | Buffer; message?: string } | null;
const stderr = e?.stderr ? e.stderr.toString().trim() : "";
const stdout = e?.stdout ? e.stdout.toString().trim() : "";
const combined = [stdout, stderr].filter(Boolean).join("\n");
return combined || e?.message || String(error);
}

/** True when a push was rejected because the remote branch has advanced past our local tip. */
function isNonFastForwardError(output: string): boolean {
return /\(fetch first\)|\(non-fast-forward\)|\[rejected\]|Updates were rejected|tip of your current branch is behind/i.test(
output,
);
}

/**
* Pushes the current HEAD to origin. If the push is rejected because the remote
* branch is ahead (non-fast-forward), fetches and rebases onto the remote tip,
* then retries. Any other failure is rethrown with git's output preserved.
*
* The repository's local git hooks (e.g. pre-push) are intentionally left
* enabled so we never silently bypass checks the repository owner configured.
* The resilience here is the non-fast-forward rebase/retry below, not skipping
* hooks.
*/
function pushWithRebaseFallback(repoLocation: string): void {
try {
git(["push", "--set-upstream", "origin", "HEAD"], repoLocation);
return;
} catch (error) {
const output = error instanceof Error ? error.message : String(error);
if (!isNonFastForwardError(output)) {
throw error;
}
console.log("[Engine SDK] Push rejected because the remote branch is ahead; fetching and rebasing...");
}

const branch = git(["rev-parse", "--abbrev-ref", "HEAD"], repoLocation).trim();
git(["fetch", "origin", branch], repoLocation);

try {
git(["rebase", `origin/${branch}`], repoLocation);
} catch (rebaseError) {
try {
execFileSync("git", ["rebase", "--abort"], { cwd: repoLocation, stdio: "pipe" });
} catch {
// best-effort cleanup
}
throw rebaseError;
}

git(["push", "--set-upstream", "origin", "HEAD"], repoLocation);
}

/**
* Stages all changes, commits with the given message, and pushes to origin.
* If there are no changes to commit, only pushes (to catch any prior local commits).
*
* The push transparently rebases onto the remote branch if it has advanced. The
* repository's local git hooks are left enabled so automated CCA pushes do not
* bypass checks the repository owner configured.
*/
export function commitAndPush(repoLocation: string, commitMessage: string): CommitAndPushResult {
const status = execFileSync("git", ["status", "--porcelain"], {
cwd: repoLocation,
encoding: "utf-8",
}).trim();
const status = git(["status", "--porcelain"], repoLocation).trim();

let hadChanges = false;

if (status) {
hadChanges = true;
execFileSync("git", ["add", "."], { cwd: repoLocation });
execFileSync("git", ["commit", "-m", commitMessage], { cwd: repoLocation });
git(["add", "."], repoLocation);
git(["commit", "-m", commitMessage], repoLocation);
}

execFileSync("git", ["push", "--set-upstream", "origin", "HEAD"], { cwd: repoLocation });
pushWithRebaseFallback(repoLocation);

return {
success: true,
Expand Down