@posthog/agent
Version:
TypeScript agent framework wrapping Claude Agent SDK with Git-based task execution for PostHog
455 lines (449 loc) • 17.3 kB
JavaScript
import { exec } from 'node:child_process';
import { promisify } from 'node:util';
import { Logger } from './utils/logger.js';
const execAsync = promisify(exec);
class GitManager {
repositoryPath;
authorName;
authorEmail;
logger;
constructor(config) {
this.repositoryPath = config.repositoryPath;
this.authorName = config.authorName;
this.authorEmail = config.authorEmail;
this.logger =
config.logger || new Logger({ debug: false, prefix: "[GitManager]" });
}
escapeShellArg(str) {
return str
.replace(/\\/g, "\\\\")
.replace(/"/g, '\\"')
.replace(/`/g, "\\`")
.replace(/\$/g, "\\$");
}
async runGitCommand(command) {
try {
const { stdout } = await execAsync(`cd "${this.repositoryPath}" && git ${command}`);
return stdout.trim();
}
catch (error) {
throw new Error(`Git command failed: ${command}\n${error}`);
}
}
async runCommand(command) {
try {
const { stdout } = await execAsync(`cd "${this.repositoryPath}" && ${command}`);
return stdout.trim();
}
catch (error) {
throw new Error(`Command failed: ${command}\n${error}`);
}
}
async isGitRepository() {
try {
await this.runGitCommand("rev-parse --git-dir");
return true;
}
catch {
return false;
}
}
async getCurrentBranch() {
return await this.runGitCommand("branch --show-current");
}
async getDefaultBranch() {
try {
// Try to get the default branch from remote
const remoteBranch = await this.runGitCommand("symbolic-ref refs/remotes/origin/HEAD");
return remoteBranch.replace("refs/remotes/origin/", "");
}
catch {
// Fallback: check if main exists, otherwise use master
if (await this.branchExists("main")) {
return "main";
}
else if (await this.branchExists("master")) {
return "master";
}
else {
throw new Error("Cannot determine default branch. No main or master branch found.");
}
}
}
async branchExists(branchName) {
try {
await this.runGitCommand(`rev-parse --verify ${branchName}`);
return true;
}
catch {
return false;
}
}
async createBranch(branchName, baseBranch) {
const base = baseBranch || (await this.getCurrentBranch());
await this.runGitCommand(`checkout -b ${branchName} ${base}`);
}
async switchToBranch(branchName) {
await this.runGitCommand(`checkout ${branchName}`);
}
async resetToDefaultBranchIfNeeded() {
const currentBranch = await this.getCurrentBranch();
const defaultBranch = await this.getDefaultBranch();
if (currentBranch === defaultBranch) {
this.logger.debug("Already on default branch", { branch: defaultBranch });
return true;
}
if (await this.hasChanges()) {
this.logger.warn("Skipping branch reset - uncommitted changes present", {
currentBranch,
defaultBranch,
});
return false;
}
await this.switchToBranch(defaultBranch);
this.logger.info("Reset to default branch", {
from: currentBranch,
to: defaultBranch,
});
return true;
}
async createOrSwitchToBranch(branchName, baseBranch) {
await this.ensureCleanWorkingDirectory("switching branches");
const exists = await this.branchExists(branchName);
if (exists) {
await this.switchToBranch(branchName);
}
else {
await this.createBranch(branchName, baseBranch);
}
}
async addFiles(paths) {
const pathList = paths.map((p) => `"${this.escapeShellArg(p)}"`).join(" ");
await this.runGitCommand(`add ${pathList}`);
}
async addAllPostHogFiles() {
try {
// Use -A flag to add all changes (including new files) and ignore errors if directory is empty
await this.runGitCommand("add -A .posthog/");
}
catch (error) {
// If the directory doesn't exist or has no files, that's fine - just log and continue
this.logger.debug("No PostHog files to add", { error });
}
}
async commitChanges(message, options) {
const command = this.buildCommitCommand(message, options);
return await this.runGitCommand(command);
}
async hasChanges() {
try {
const status = await this.runGitCommand("status --porcelain");
if (!status || status.trim().length === 0) {
return false;
}
const lines = status.split("\n").filter((line) => {
const trimmed = line.trim();
return trimmed.length > 0 && !trimmed.includes(".posthog/");
});
return lines.length > 0;
}
catch {
return false;
}
}
async hasStagedChanges() {
try {
const status = await this.runGitCommand("diff --cached --name-only");
return status.length > 0;
}
catch {
return false;
}
}
// Helper: Centralized safety check for uncommitted changes
async ensureCleanWorkingDirectory(operation) {
if (await this.hasChanges()) {
throw new Error(`Uncommitted changes detected. Please commit or stash changes before ${operation}.`);
}
}
async generateUniqueBranchName(baseName) {
if (!(await this.branchExists(baseName))) {
return baseName;
}
let counter = 1;
let uniqueName = `${baseName}-${counter}`;
while (await this.branchExists(uniqueName)) {
counter++;
uniqueName = `${baseName}-${counter}`;
}
return uniqueName;
}
async ensureOnDefaultBranch() {
const defaultBranch = await this.getDefaultBranch();
const currentBranch = await this.getCurrentBranch();
if (currentBranch !== defaultBranch) {
await this.ensureCleanWorkingDirectory("switching to default branch");
await this.switchToBranch(defaultBranch);
}
return defaultBranch;
}
buildCommitCommand(message, options) {
let command = `commit -m "${this.escapeShellArg(message)}"`;
if (options?.allowEmpty) {
command += " --allow-empty";
}
const authorName = options?.authorName || this.authorName;
const authorEmail = options?.authorEmail || this.authorEmail;
if (authorName && authorEmail) {
command += ` --author="${authorName} <${authorEmail}>"`;
}
return command;
}
async getRemoteUrl() {
try {
return await this.runGitCommand("remote get-url origin");
}
catch {
return null;
}
}
async pushBranch(branchName, force = false) {
const forceFlag = force ? "--force" : "";
await this.runGitCommand(`push ${forceFlag} -u origin ${branchName}`);
}
/**
* Tracks whether commits were made during an operation by comparing HEAD SHA
* before and after. Returns an object with methods to finalize the operation.
*
* Usage:
* const tracker = await gitManager.trackCommitsDuring();
* // ... do work that might create commits ...
* const result = await tracker.finalize({ commitMessage: 'fallback message', push: true });
*/
async trackCommitsDuring() {
const initialSha = await this.getCommitSha("HEAD");
return {
finalize: async (options) => {
const currentSha = await this.getCommitSha("HEAD");
const externalCommitsCreated = initialSha !== currentSha;
const hasUncommittedChanges = await this.hasChanges();
// If no commits and no changes, nothing to do
if (!externalCommitsCreated && !hasUncommittedChanges) {
return { commitCreated: false, pushedBranch: false };
}
let commitCreated = externalCommitsCreated;
// Commit any remaining uncommitted changes
if (hasUncommittedChanges) {
await this.runGitCommand("add .");
const hasStagedChanges = await this.hasStagedChanges();
if (hasStagedChanges) {
await this.commitChanges(options.commitMessage);
commitCreated = true;
}
}
// Push if requested and commits were made
let pushedBranch = false;
if (options.push && commitCreated) {
const currentBranch = await this.getCurrentBranch();
await this.pushBranch(currentBranch);
pushedBranch = true;
this.logger.info("Pushed branch after operation", {
branch: currentBranch,
});
}
return { commitCreated, pushedBranch };
},
};
}
async createTaskBranch(taskSlug) {
const branchName = `posthog/task-${taskSlug}`;
// Ensure we're on default branch before creating task branch
const defaultBranch = await this.ensureOnDefaultBranch();
this.logger.info("Creating task branch from default branch", {
branchName,
taskSlug,
baseBranch: defaultBranch,
});
await this.createOrSwitchToBranch(branchName, defaultBranch);
return branchName;
}
async createTaskPlanningBranch(taskId, baseBranch) {
const baseName = `posthog/task-${taskId}-planning`;
const branchName = await this.generateUniqueBranchName(baseName);
this.logger.debug("Creating unique planning branch", {
branchName,
taskId,
});
const base = baseBranch || (await this.ensureOnDefaultBranch());
await this.createBranch(branchName, base);
return branchName;
}
async createTaskImplementationBranch(taskId, planningBranchName) {
const baseName = `posthog/task-${taskId}-implementation`;
const branchName = await this.generateUniqueBranchName(baseName);
this.logger.debug("Creating unique implementation branch", {
branchName,
taskId,
currentBranch: await this.getCurrentBranch(),
});
// Determine base branch: explicit param > current planning branch > default
let baseBranch = planningBranchName;
if (!baseBranch) {
const currentBranch = await this.getCurrentBranch();
if (currentBranch.includes("-planning")) {
baseBranch = currentBranch;
this.logger.debug("Using current planning branch", { baseBranch });
}
else {
baseBranch = await this.ensureOnDefaultBranch();
this.logger.debug("Using default branch", { baseBranch });
}
}
this.logger.debug("Creating implementation branch from base", {
baseBranch,
branchName,
});
await this.createBranch(branchName, baseBranch);
this.logger.info("Implementation branch created", {
branchName,
currentBranch: await this.getCurrentBranch(),
});
return branchName;
}
async commitPlan(taskId, taskTitle) {
const currentBranch = await this.getCurrentBranch();
this.logger.debug("Committing plan", { taskId, currentBranch });
await this.addAllPostHogFiles();
const hasChanges = await this.hasStagedChanges();
this.logger.debug("Checking for staged changes", { hasChanges });
if (!hasChanges) {
this.logger.info("No plan changes to commit", { taskId });
return "No changes to commit";
}
const message = `📋 Add plan for task: ${taskTitle}
Task ID: ${taskId}
Generated by PostHog Agent
This commit contains the implementation plan and supporting documentation
for the task. Review the plan before proceeding with implementation.`;
const result = await this.commitChanges(message);
this.logger.info("Plan committed", { taskId, taskTitle });
return result;
}
async commitImplementation(taskId, taskTitle, planSummary) {
await this.runGitCommand("add .");
const hasChanges = await this.hasStagedChanges();
if (!hasChanges) {
this.logger.warn("No implementation changes to commit", { taskId });
return "No changes to commit";
}
let message = `✨ Implement task: ${taskTitle}
Task ID: ${taskId}
Generated by PostHog Agent`;
if (planSummary) {
message += `\n\nPlan Summary:\n${planSummary}`;
}
message += `\n\nThis commit implements the changes described in the task plan.`;
const result = await this.commitChanges(message);
this.logger.info("Implementation committed", { taskId, taskTitle });
return result;
}
async deleteBranch(branchName, force = false) {
const forceFlag = force ? "-D" : "-d";
await this.runGitCommand(`branch ${forceFlag} ${branchName}`);
}
async deleteRemoteBranch(branchName) {
await this.runGitCommand(`push origin --delete ${branchName}`);
}
async getBranchInfo(branchName) {
const exists = await this.branchExists(branchName);
const currentBranch = await this.getCurrentBranch();
return {
name: branchName,
exists,
isCurrentBranch: branchName === currentBranch,
};
}
async getCommitSha(ref = "HEAD") {
return await this.runGitCommand(`rev-parse ${ref}`);
}
async getCommitMessage(ref = "HEAD") {
return await this.runGitCommand(`log -1 --pretty=%B ${ref}`);
}
async createPullRequest(branchName, title, body, baseBranch) {
const currentBranch = await this.getCurrentBranch();
if (currentBranch !== branchName) {
await this.ensureCleanWorkingDirectory("creating PR");
await this.switchToBranch(branchName);
}
await this.pushBranch(branchName);
let command = `gh pr create --title "${this.escapeShellArg(title)}" --body "${this.escapeShellArg(body)}"`;
if (baseBranch) {
command += ` --base ${baseBranch}`;
}
try {
const prUrl = await this.runCommand(command);
return prUrl.trim();
}
catch (error) {
throw new Error(`Failed to create PR: ${error}`);
}
}
async getTaskBranch(taskSlug) {
try {
// Get all branches matching the task slug pattern
const branches = await this.runGitCommand("branch --list --all");
const branchPattern = `posthog/task-${taskSlug}`;
// Look for exact match or with counter suffix
const lines = branches
.split("\n")
.map((l) => l.trim().replace(/^\*\s+/, ""));
for (const line of lines) {
const cleanBranch = line.replace("remotes/origin/", "");
if (cleanBranch.startsWith(branchPattern)) {
return cleanBranch;
}
}
return null;
}
catch (error) {
this.logger.debug("Failed to get task branch", { taskSlug, error });
return null;
}
}
async commitAndPush(message, options) {
const hasChanges = await this.hasStagedChanges();
if (!hasChanges && !options?.allowEmpty) {
this.logger.debug("No changes to commit, skipping");
return;
}
const command = this.buildCommitCommand(message, options);
await this.runGitCommand(command);
// Push to origin
const currentBranch = await this.getCurrentBranch();
await this.pushBranch(currentBranch);
this.logger.info("Committed and pushed changes", {
branch: currentBranch,
message,
});
}
async isWorktree() {
try {
// In a worktree, .git is a file pointing to the main repo's .git/worktrees/{name}
// In a normal repo, .git is a directory
const result = await this.runGitCommand("rev-parse --git-common-dir --git-dir");
const lines = result.split("\n");
if (lines.length >= 2) {
const commonDir = lines[0].trim();
const gitDir = lines[1].trim();
// If they're different, we're in a worktree
return commonDir !== gitDir;
}
return false;
}
catch {
return false;
}
}
}
export { GitManager };
//# sourceMappingURL=git-manager.js.map