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
4 changes: 2 additions & 2 deletions CLAUDE.md
Original file line number Diff line number Diff line change
Expand Up @@ -6,7 +6,7 @@ This file provides guidance to Claude Code (claude.ai/code) when working with co

Staqd (`/stakt/`) is a stacked PR tool with two components:
1. **GitHub Action** — Composite action for managing stacked PRs via PR comment commands (`st merge`, `st restack`, `st discover`, `st merge-all`, `st help`)
2. **CLI** (`st`) — Local CLI for syncing, restacking, submitting, and moving branches (`st sync`, `st restack`, `st submit`, `st move`)
2. **CLI** (`st`) — Local CLI for managing stacked branches (`st track`, `st create`, `st submit`, `st log`, `st up`, `st down`, `st sync`, `st restack`, `st move`)

Written in plain JavaScript (ESM), no external npm dependencies. Distributed via `npx staqd` or `npm i -g staqd`.

Expand Down Expand Up @@ -55,7 +55,7 @@ Supports both `GITHUB_TOKEN` and GitHub App tokens (via `app-id` + `app-private-
- **`cli/index.mjs`** — Command dispatcher and flag parser
- **`cli/git.mjs`** — Git and `gh` CLI wrappers (no Octokit, uses `child_process.execSync`)
- **`cli/stack.mjs`** — Stack tree discovery from PR list (`gh pr list` → build tree from base/head relationships)
- **`cli/commands/`** — One file per command: `sync.mjs`, `restack.mjs`, `submit.mjs`, `move.mjs`
- **`cli/commands/`** — One file per command: `sync.mjs`, `restack.mjs`, `submit.mjs`, `move.mjs`, `track.mjs`, `create.mjs`, `log.mjs`, `navigate.mjs`

## Code Conventions

Expand Down
27 changes: 27 additions & 0 deletions cli/commands/create.mjs
Original file line number Diff line number Diff line change
@@ -0,0 +1,27 @@
// st create — Create a new branch and auto-track it.

import * as git from '../git.mjs';

export function create(flags) {
const name = flags._[0];
if (!name) throw new Error('Usage: st create <branch-name>');

const current = git.currentBranch();
if (!current) throw new Error('Not on a branch (detached HEAD).');

const defBranch = git.defaultBranch();

// Current branch must be default branch or tracked
if (current !== defBranch && !git.getTrackedParent(current)) {
throw new Error(
`Current branch "${current}" is not tracked. Track it first with: st track`
);
}

// Create and checkout new branch
git.checkoutNew(name);

// Auto-track with current branch as parent
git.setTrackedParent(name, current);
console.log(`\x1b[32m✓\x1b[0m Created and tracking \x1b[1m${name}\x1b[0m → \x1b[1m${current}\x1b[0m`);
}
42 changes: 42 additions & 0 deletions cli/commands/log.mjs
Original file line number Diff line number Diff line change
@@ -0,0 +1,42 @@
// st log — Visualize the tracked stack tree.

import * as git from '../git.mjs';

export function log() {
const tracked = git.listTrackedBranches();
const current = git.currentBranch();
const defBranch = git.defaultBranch();

if (tracked.length === 0) {
console.log('No tracked branches. Use \x1b[1mst track\x1b[0m to start.');
return;
}

// Build parent → children map
const childrenOf = new Map();
for (const { branch, parent } of tracked) {
if (!childrenOf.has(parent)) childrenOf.set(parent, []);
childrenOf.get(parent).push(branch);
}

// Print tree starting from default branch roots
console.log(`\x1b[1m${defBranch}\x1b[0m`);
const roots = childrenOf.get(defBranch) || [];
for (let i = 0; i < roots.length; i++) {
const isLast = i === roots.length - 1;
printBranch(roots[i], '', isLast, childrenOf, current);

Check warning on line 27 in cli/commands/log.mjs

View workflow job for this annotation

GitHub Actions / lint

Function Call Object Injection Sink

Check warning on line 27 in cli/commands/log.mjs

View workflow job for this annotation

GitHub Actions / lint

Function Call Object Injection Sink
}
}

function printBranch(branch, prefix, isLast, childrenOf, current) {
const connector = isLast ? '└─ ' : '├─ ';
const marker = branch === current ? ' \x1b[36m◀\x1b[0m' : '';
console.log(`${prefix}${connector}\x1b[1m${branch}\x1b[0m${marker}`);

const children = childrenOf.get(branch) || [];
const childPrefix = prefix + (isLast ? ' ' : '│ ');
for (let i = 0; i < children.length; i++) {
const childIsLast = i === children.length - 1;
printBranch(children[i], childPrefix, childIsLast, childrenOf, current);

Check warning on line 40 in cli/commands/log.mjs

View workflow job for this annotation

GitHub Actions / lint

Function Call Object Injection Sink

Check warning on line 40 in cli/commands/log.mjs

View workflow job for this annotation

GitHub Actions / lint

Function Call Object Injection Sink
}
}
38 changes: 38 additions & 0 deletions cli/commands/navigate.mjs
Original file line number Diff line number Diff line change
@@ -0,0 +1,38 @@
// st up / st down — Navigate within the tracked stack.

import * as git from '../git.mjs';

export function up() {
const current = git.currentBranch();
if (!current) throw new Error('Not on a branch (detached HEAD).');

const tracked = git.listTrackedBranches();
const children = tracked.filter(t => t.parent === current);

if (children.length === 0) {
throw new Error('Already at top of stack (no children).');
}

// If multiple children, pick the first one
const target = children[0].branch;
if (children.length > 1) {
console.log(`\x1b[33m!\x1b[0m Multiple children: ${children.map(c => c.branch).join(', ')}`);
console.log(` Checking out first: ${target}`);
}

git.checkout(target);
console.log(`\x1b[32m✓\x1b[0m \x1b[1m${target}\x1b[0m`);
}

export function down() {
const current = git.currentBranch();
if (!current) throw new Error('Not on a branch (detached HEAD).');

const parent = git.getTrackedParent(current);
if (!parent) {
throw new Error('Already at bottom of stack (not tracked or no parent).');
}

git.checkout(parent);
console.log(`\x1b[32m✓\x1b[0m \x1b[1m${parent}\x1b[0m`);
}
53 changes: 37 additions & 16 deletions cli/commands/submit.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -11,34 +11,55 @@ export async function submit(flags) {
throw new Error('Not on a branch (detached HEAD).');
}

// Collect tracked chain: walk from current up to root
const chain = [];
let b = current;
while (b) {
const parent = git.getTrackedParent(b);
if (parent) {
chain.push(b);
b = parent;
} else {
// b is either default branch (stop) or untracked (include current only)
if (b !== git.defaultBranch()) chain.push(b);
break;
}
}
chain.reverse(); // root-first order

// If no tracked chain, fall back to just the current branch
if (chain.length === 0) chain.push(current);

// Push current branch first
console.log(`Pushing ${current}...`);
if (!dryRun) {
git.pushUpstream(current);
}

// Discover stack from PRs
const prs = git.ghPrList();
const { roots, nodes } = buildStackTree(prs);

// Check if current branch already has a PR
const existingPr = prs.find(p => p.headRefName === current);
// Discover existing PRs
let prs = git.ghPrList();

if (!existingPr) {
// Need to create a PR — figure out the base branch
const base = detectBase(current, prs);
// Create PRs for each tracked branch in the chain (root → leaf)
const defBranch = git.defaultBranch();
for (const branch of chain) {
const existingPr = prs.find(p => p.headRefName === branch);
if (existingPr) {
console.log(` \x1b[90m·\x1b[0m PR #${existingPr.number} already exists for ${branch}`);
continue;
}

console.log(`Creating PR: ${current} → ${base}`);
const base = git.getTrackedParent(branch) || detectBase(branch, prs);
console.log(`Creating PR: ${branch} → ${base}`);
if (!dryRun) {
// Generate title from branch name
const title = branchToTitle(current);
const url = git.ghPrCreate(current, base, title);
git.pushUpstream(branch);
const title = branchToTitle(branch);
const url = git.ghPrCreate(branch, base, title);
console.log(` \x1b[32m✓\x1b[0m Created: ${url}`);
// Re-fetch so subsequent iterations see the new PR
prs = git.ghPrList();
} else {
console.log(` \x1b[33m~\x1b[0m Would create PR: ${current} → ${base}`);
console.log(` \x1b[33m~\x1b[0m Would create PR: ${branch} → ${base}`);
}
} else {
console.log(` \x1b[90m·\x1b[0m PR #${existingPr.number} already exists`);
}

// Re-fetch PR list after potential creation
Expand Down
110 changes: 110 additions & 0 deletions cli/commands/track.mjs
Original file line number Diff line number Diff line change
@@ -0,0 +1,110 @@
// st track — Register branches in the stack.

import * as git from '../git.mjs';

export function track(flags) {
if (flags.list) {
listTracked();
return;
}

const current = git.currentBranch();
if (!current) throw new Error('Not on a branch (detached HEAD).');

const defBranch = git.defaultBranch();
if (current === defBranch) {
throw new Error(`Cannot track the default branch (${defBranch}).`);
}

const parent = flags.parent || detectParent(current);

// Validate parent chain reaches default branch
if (!validateParent(parent)) {
throw new Error(
`Cannot track: parent "${parent}" is not tracked and does not reach ${defBranch}.\n` +
`Either track "${parent}" first, or use --parent to specify a valid parent.`
);
}

git.setTrackedParent(current, parent);
console.log(`\x1b[32m✓\x1b[0m Tracking \x1b[1m${current}\x1b[0m → \x1b[1m${parent}\x1b[0m`);
}

export function untrack() {
const current = git.currentBranch();
if (!current) throw new Error('Not on a branch (detached HEAD).');

const parent = git.getTrackedParent(current);
if (!parent) {
console.log(`\x1b[90m·\x1b[0m ${current} is not tracked`);
return;
}

git.unsetTrackedParent(current);
console.log(`\x1b[32m✓\x1b[0m Untracked \x1b[1m${current}\x1b[0m`);
}

function listTracked() {
const tracked = git.listTrackedBranches();
const defBranch = git.defaultBranch();
const current = git.currentBranch();

if (tracked.length === 0) {
console.log('No tracked branches.');
return;
}

console.log('\x1b[1mTracked branches:\x1b[0m\n');
for (const { branch, parent } of tracked) {
const marker = branch === current ? ' \x1b[36m◀\x1b[0m' : '';
console.log(` \x1b[1m${branch}\x1b[0m → ${parent}${marker}`);
}
}

/**
* Detect the parent for the current branch.
* Finds the closest tracked ancestor or the default branch.
*/
function detectParent(branch) {
const defBranch = git.defaultBranch();
const tracked = git.listTrackedBranches();

// Check tracked branches that are ancestors of the current branch
let best = null;
let bestDistance = Infinity;

for (const { branch: trackedBranch } of tracked) {
const mb = git.mergeBase(trackedBranch, branch);
const sha = git.localSha(trackedBranch);
if (mb && sha && mb === sha) {
const distance = git.commitDistance(sha, branch);
if (distance < bestDistance) {
best = trackedBranch;
bestDistance = distance;
}
}
}

return best || defBranch;
}

/**
* Validate that a parent is either the default branch or a tracked branch
* whose chain reaches the default branch.
*/
function validateParent(parent) {
const defBranch = git.defaultBranch();
if (parent === defBranch) return true;

let b = parent;
const visited = new Set();
while (b) {
if (visited.has(b)) return false; // cycle protection
visited.add(b);
const p = git.getTrackedParent(b);
if (!p) return false; // chain broken
if (p === defBranch) return true;
b = p;
}
return false;
}
31 changes: 31 additions & 0 deletions cli/git.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -98,6 +98,10 @@ export function checkout(branch) {
run('git', ['checkout', branch]);
}

export function checkoutNew(branch) {
run('git', ['checkout', '-b', branch]);
}

export function ensureLocalBranch(branch) {
run('git', ['branch', branch, `origin/${branch}`], { silent: true });
}
Expand All @@ -106,6 +110,33 @@ export function deleteBranch(branch) {
run('git', ['branch', '-D', branch], { silent: true });
}

// ── Track helpers (git config local) ──

export function getTrackedParent(branch) {
return run('git', ['config', '--local', `branch.${branch}.staqd-parent`], { silent: true });
}

export function setTrackedParent(branch, parent) {
run('git', ['config', '--local', `branch.${branch}.staqd-parent`, parent]);
}

export function unsetTrackedParent(branch) {
run('git', ['config', '--local', '--unset', `branch.${branch}.staqd-parent`], { silent: true });
}

export function listTrackedBranches() {
const out = run('git', ['config', '--local', '--get-regexp', 'branch\\..*\\.staqd-parent'], { silent: true });
if (!out) return [];
return out.split('\n').filter(Boolean).map(line => {
const spaceIdx = line.indexOf(' ');
const key = line.slice(0, spaceIdx);
const parent = line.slice(spaceIdx + 1);
const match = key.match(/^branch\.(.+)\.staqd-parent$/);
const branch = match ? match[1] : key;
return { branch, parent };
});
}

// ── gh CLI helpers ──

export function ghPrList() {
Expand Down
13 changes: 12 additions & 1 deletion cli/index.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -2,8 +2,12 @@
import { restack } from './commands/restack.mjs';
import { submit } from './commands/submit.mjs';
import { move } from './commands/move.mjs';
import { track, untrack } from './commands/track.mjs';
import { create } from './commands/create.mjs';
import { log } from './commands/log.mjs';
import { up, down } from './commands/navigate.mjs';

const COMMANDS = { sync, restack, submit, move };
const COMMANDS = { sync, restack, submit, move, track, untrack, create, log, up, down };

const HELP = `\x1b[1mstaqd\x1b[0m — Stacked PR CLI

Expand All @@ -15,6 +19,12 @@
restack Locally rebase stack branches onto their parents
submit Push branches and create/update PRs
move Move current branch to a new parent
track Register current branch in the stack (--parent, --list)
untrack Remove current branch from the stack
create Create a new branch and auto-track it
log Visualize the tracked stack tree
up Move to child branch in the stack
down Move to parent branch in the stack

\x1b[1mOPTIONS\x1b[0m
--help Show help
Expand All @@ -28,7 +38,7 @@
return;
}

const handler = COMMANDS[command];

Check warning on line 41 in cli/index.mjs

View workflow job for this annotation

GitHub Actions / lint

Variable Assigned to Object Injection Sink

Check warning on line 41 in cli/index.mjs

View workflow job for this annotation

GitHub Actions / lint

Variable Assigned to Object Injection Sink
if (!handler) {
console.error(`Unknown command: ${command}\n`);
console.log(HELP);
Expand All @@ -44,10 +54,11 @@
for (const arg of args) {
if (arg.startsWith('--')) {
const [key, val] = arg.slice(2).split('=');
flags[key] = val ?? true;

Check warning on line 57 in cli/index.mjs

View workflow job for this annotation

GitHub Actions / lint

Generic Object Injection Sink

Check warning on line 57 in cli/index.mjs

View workflow job for this annotation

GitHub Actions / lint

Generic Object Injection Sink
} else {
flags._.push(arg);
}
}
return flags;
}
// feature C
Loading