Skip to content
Merged
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
12 changes: 0 additions & 12 deletions src/commands/config.ts
Original file line number Diff line number Diff line change
Expand Up @@ -18,7 +18,6 @@ async function runConfig(options: { show?: boolean }): Promise<void> {
if (config.anthropicApiKey)
console.log(` anthropicApiKey: ${theme.muted(redact(config.anthropicApiKey))}`);
console.log(` autoStash: ${theme.primary(config.autoStash)}`);
console.log(` syncPull: ${theme.primary(config.syncPull)}`);
console.log(` autoDeleteMerged: ${theme.primary(config.autoDeleteMerged)}`);
console.log(` autoUpdateTicketStatus: ${theme.primary(config.autoUpdateTicketStatus)}`);
if (config.integrations.jira?.enabled) {
Expand Down Expand Up @@ -70,16 +69,6 @@ async function runConfig(options: { show?: boolean }): Promise<void> {
initialValue: existing?.autoStash ?? 'ask',
});

const syncPull = await select({
message: 'Pull base branch before creating a new branch?',
options: [
{ value: 'ask', label: 'Ask each time' },
{ value: 'always', label: 'Always pull automatically' },
{ value: 'never', label: 'Never pull' },
],
initialValue: existing?.syncPull ?? 'ask',
});

const autoDeleteMerged = await select({
message: 'Delete local branch after PR is merged?',
options: [
Expand Down Expand Up @@ -177,7 +166,6 @@ async function runConfig(options: { show?: boolean }): Promise<void> {
anthropicApiKey,
autoStash,
lastStashChoice: existing?.lastStashChoice,
syncPull,
autoDeleteMerged,
autoUpdateTicketStatus,
integrations: { jira: jiraConfig, slack: slackConfig, notion: notionConfig },
Expand Down
1 change: 0 additions & 1 deletion src/commands/init.ts
Original file line number Diff line number Diff line change
Expand Up @@ -138,7 +138,6 @@ async function runInit(): Promise<void> {
githubUsername: globalConfig.githubUsername,
githubRepo,
defaultBranch,
syncPull: existingProjectConfig?.syncPull ?? 'ask',
integrations: {
github: { enabled: true },
jira:
Expand Down
115 changes: 52 additions & 63 deletions src/commands/sync.ts
Original file line number Diff line number Diff line change
Expand Up @@ -25,46 +25,59 @@ async function hasDiverged(branch: string, defaultBranch: string): Promise<boole
return result.stdout.trim().length > 0;
}

async function runSync(): Promise<void> {
async function runSync(options: { all?: boolean }): Promise<void> {
const projectId = await requireTrackedRepo();
const [globalConfig, projectConfig] = await Promise.all([
configManager.getGlobalConfig(),
configManager.getProjectConfig(projectId),
]);
const { defaultBranch } = projectConfig;

// Project-level overrides global; fall back to global default
const syncPull = projectConfig.syncPull ?? globalConfig.syncPull;
const autoDeleteMerged = projectConfig.autoDeleteMerged ?? globalConfig.autoDeleteMerged;
const autoUpdateTicketStatus =
projectConfig.autoUpdateTicketStatus ?? globalConfig.autoUpdateTicketStatus;

// ── Step 1: Pull default branch ──────────────────────────────────────────────
let shouldPull = false;
if (syncPull === 'always') {
shouldPull = true;
} else if (syncPull === 'ask') {
shouldPull = await confirm({ message: `Pull latest ${defaultBranch}?` });
// ── Step 1: Always pull default branch ───────────────────────────────────────
const startBranch = await getCurrentBranch();
if (startBranch !== defaultBranch) {
await checkout(defaultBranch);
}
// 'never' → shouldPull stays false

if (shouldPull) {
const currentBranch = await getCurrentBranch();
if (currentBranch !== defaultBranch) {
await checkout(defaultBranch);
}
await withSpinner(`Pulling ${defaultBranch}...`, () => pullBranch(defaultBranch));
if (currentBranch !== defaultBranch) {
await checkout(currentBranch);
}
console.log(theme.success(` ${symbols.success} Pulled latest ${defaultBranch}`));
await withSpinner(`Pulling ${defaultBranch}...`, () => pullBranch(defaultBranch));
if (startBranch !== defaultBranch) {
await checkout(startBranch);
}
console.log(theme.success(` ${symbols.success} Pulled latest ${defaultBranch}`));

// ── Step 2: Load branches ────────────────────────────────────────────────────
// ── Step 2: Sync PR statuses (all tracked branches) ──────────────────────────
const branchesFile = await configManager.getBranches(projectId);
const allBranches = branchesFile.branches;
const syncableBranches = allBranches.filter((b) => ['active', 'pr_open'].includes(b.status));

// ── Step 3: Offer to clean up merged branches ────────────────────────────────
let updated = 0;
for (const branch of syncableBranches) {
const pr = await withSpinner(`Checking ${branch.branchName}...`, () =>
ghClient.getPRForBranch(branch.branchName),
);
if (!pr) continue;

const prStatus = ghPrToPrStatus(pr);
const branchStatus = pr.mergedAt ? ('pr_merged' as const) : ('pr_open' as const);

if (branch.prStatus !== prStatus || branch.status !== branchStatus) {
branch.prNumber = pr.number;
branch.prUrl = pr.url;
branch.prStatus = prStatus;
branch.status = branchStatus;
branch.updatedAt = new Date().toISOString();
updated++;
console.log(theme.success(` ${symbols.success} ${branch.branchName} → ${prStatus}`));
}
}

if (updated > 0) {
console.log(theme.success(` Synced ${updated} PR status(es).`));
}

// ── Step 3: Delete merged branches (all tracked branches) ────────────────────
const mergedBranches = allBranches.filter((b) => b.status === 'pr_merged');
for (const branch of mergedBranches) {
let doDelete = false;
Expand All @@ -81,7 +94,6 @@ async function runSync(): Promise<void> {
initialValue: true,
});
}
// 'never' → doDelete stays false
if (doDelete) {
const currentBranch = await getCurrentBranch();
if (currentBranch === branch.branchName) {
Expand All @@ -103,9 +115,15 @@ async function runSync(): Promise<void> {
}
}

// ── Step 4: Offer rebase/merge for active branches that have diverged ────────
// ── Step 4: Update branches with new changes from default branch ──────────────
// Scope: current branch only by default; all active branches if --all
const currentBranch = await getCurrentBranch();
const activeBranches = allBranches.filter((b) => ['active', 'pr_open'].includes(b.status));
for (const branch of activeBranches) {
const branchesToUpdate = options.all
? activeBranches
: activeBranches.filter((b) => b.branchName === currentBranch);

for (const branch of branchesToUpdate) {
const diverged = await hasDiverged(branch.branchName, defaultBranch);
if (!diverged) continue;

Expand All @@ -120,7 +138,6 @@ async function runSync(): Promise<void> {

if (action === 'skip') continue;

const currentBranch = await getCurrentBranch();
if (currentBranch !== branch.branchName) {
await checkout(branch.branchName);
}
Expand Down Expand Up @@ -150,45 +167,17 @@ async function runSync(): Promise<void> {
}
}

// ── Step 5: Sync PR statuses with GitHub ────────────────────────────────────
const syncableBranches = allBranches.filter((b) => ['active', 'pr_open'].includes(b.status));

if (syncableBranches.length === 0) {
await configManager.saveBranches(projectId, branchesFile);
console.log(theme.muted('No active branches to sync with GitHub.'));
return;
}

let updated = 0;
for (const branch of syncableBranches) {
const pr = await withSpinner(`Checking ${branch.branchName}...`, () =>
ghClient.getPRForBranch(branch.branchName),
);
if (!pr) continue;

const prStatus = ghPrToPrStatus(pr);
const branchStatus = pr.mergedAt ? ('pr_merged' as const) : ('pr_open' as const);

if (branch.prStatus !== prStatus || branch.status !== branchStatus) {
branch.prNumber = pr.number;
branch.prUrl = pr.url;
branch.prStatus = prStatus;
branch.status = branchStatus;
branch.updatedAt = new Date().toISOString();
updated++;
console.log(theme.success(` ${symbols.success} ${branch.branchName} → ${prStatus}`));
}
}

await configManager.saveBranches(projectId, branchesFile);

if (updated > 0) {
console.log(theme.success(`\nSynced ${updated} branch(es).`));
} else {
console.log(theme.muted('All branches up to date.'));
if (activeBranches.length === 0 && mergedBranches.length === 0) {
console.log(theme.muted('No branches to sync.'));
}
}

export function registerSyncCommand(program: Command): void {
program.command('sync').description('Sync branch statuses with GitHub PRs').action(runSync);
program
.command('sync')
.description('Sync branch statuses with GitHub PRs and update branches')
.option('--all', 'Update all active branches (default: current branch only)')
.action(runSync);
}
2 changes: 0 additions & 2 deletions src/config/schemas.ts
Original file line number Diff line number Diff line change
Expand Up @@ -32,7 +32,6 @@ export const GlobalConfigSchema = z.object({
anthropicApiKey: z.string().min(1).optional(),
autoStash: z.enum(['always', 'ask', 'never']).default('ask'),
lastStashChoice: z.enum(['stash', 'skip']).optional(),
syncPull: z.enum(['always', 'ask', 'never']).default('ask'),
autoDeleteMerged: z.enum(['always', 'ask', 'never']).default('ask'),
autoUpdateTicketStatus: z.enum(['always', 'ask', 'never']).default('ask'),
integrations: GlobalIntegrationsSchema.default({}),
Expand Down Expand Up @@ -97,7 +96,6 @@ export const ProjectConfigSchema = z.object({
githubUsername: z.string().min(1),
githubRepo: z.string().min(1),
defaultBranch: z.string().min(1).default('main'),
syncPull: z.enum(['always', 'ask', 'never']).optional(),
autoDeleteMerged: z.enum(['always', 'ask', 'never']).optional(),
autoUpdateTicketStatus: z.enum(['always', 'ask', 'never']).optional(),
integrations: ProjectIntegrationsSchema.default({}),
Expand Down