From baf68827e3abc826587754e28d7e57901a98f8c5 Mon Sep 17 00:00:00 2001 From: siner308 Date: Wed, 18 Mar 2026 18:42:42 +0900 Subject: [PATCH 1/3] test: add feature C --- cli/index.mjs | 1 + 1 file changed, 1 insertion(+) diff --git a/cli/index.mjs b/cli/index.mjs index 7dc5a0a..78c420d 100644 --- a/cli/index.mjs +++ b/cli/index.mjs @@ -51,3 +51,4 @@ function parseFlags(args) { } return flags; } +// feature C From c6d33fa43205c157765eaab180a0970cd496071a Mon Sep 17 00:00:00 2001 From: siner308 Date: Wed, 18 Mar 2026 19:09:08 +0900 Subject: [PATCH 2/3] feat: add st track, create, log, up, down and stack-wide submit MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Add explicit branch tracking via git config (staqd-parent), replacing implicit merge-base detection for stack PR creation. st submit now walks the tracked chain root→leaf to create PRs for the entire stack. New commands: track, untrack, create, log, up, down. Co-Authored-By: Claude Opus 4.6 (1M context) noreply@anthropic.com mailto:noreply@anthropic.com --- CLAUDE.md | 4 +- cli/commands/create.mjs | 28 ++++++++++ cli/commands/log.mjs | 42 +++++++++++++++ cli/commands/navigate.mjs | 38 +++++++++++++ cli/commands/submit.mjs | 53 ++++++++++++------ cli/commands/track.mjs | 110 ++++++++++++++++++++++++++++++++++++++ cli/git.mjs | 26 +++++++++ cli/index.mjs | 12 ++++- 8 files changed, 294 insertions(+), 19 deletions(-) create mode 100644 cli/commands/create.mjs create mode 100644 cli/commands/log.mjs create mode 100644 cli/commands/navigate.mjs create mode 100644 cli/commands/track.mjs diff --git a/CLAUDE.md b/CLAUDE.md index f3b06d5..57638e2 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -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`. @@ -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 diff --git a/cli/commands/create.mjs b/cli/commands/create.mjs new file mode 100644 index 0000000..f4c1562 --- /dev/null +++ b/cli/commands/create.mjs @@ -0,0 +1,28 @@ +// st create — Create a new branch and auto-track it. + +import { execFileSync } from 'node:child_process'; +import * as git from '../git.mjs'; + +export function create(flags) { + const name = flags._[0]; + if (!name) throw new Error('Usage: st create '); + + 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 + execFileSync('git', ['checkout', '-b', name], { encoding: 'utf-8', stdio: 'pipe' }); + + // 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`); +} diff --git a/cli/commands/log.mjs b/cli/commands/log.mjs new file mode 100644 index 0000000..597f066 --- /dev/null +++ b/cli/commands/log.mjs @@ -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); + } +} + +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); + } +} diff --git a/cli/commands/navigate.mjs b/cli/commands/navigate.mjs new file mode 100644 index 0000000..73609ee --- /dev/null +++ b/cli/commands/navigate.mjs @@ -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`); +} diff --git a/cli/commands/submit.mjs b/cli/commands/submit.mjs index 2a4def7..8e9b2df 100644 --- a/cli/commands/submit.mjs +++ b/cli/commands/submit.mjs @@ -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 diff --git a/cli/commands/track.mjs b/cli/commands/track.mjs new file mode 100644 index 0000000..50eac82 --- /dev/null +++ b/cli/commands/track.mjs @@ -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; +} diff --git a/cli/git.mjs b/cli/git.mjs index 2cd845b..8ea2042 100644 --- a/cli/git.mjs +++ b/cli/git.mjs @@ -106,6 +106,32 @@ 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 branch = key.replace(/^branch\./, '').replace(/\.staqd-parent$/, ''); + return { branch, parent }; + }); +} + // ── gh CLI helpers ── export function ghPrList() { diff --git a/cli/index.mjs b/cli/index.mjs index 78c420d..a9d53b1 100644 --- a/cli/index.mjs +++ b/cli/index.mjs @@ -2,8 +2,12 @@ import { sync } from './commands/sync.mjs'; 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 @@ -15,6 +19,12 @@ const HELP = `\x1b[1mstaqd\x1b[0m — Stacked PR CLI 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 From d6e8b714a97354332864606701576f8207cda6a9 Mon Sep 17 00:00:00 2001 From: siner308 Date: Wed, 18 Mar 2026 19:11:43 +0900 Subject: [PATCH 3/3] =?UTF-8?q?=20=20fix:=20address=20code=20review=20?= =?UTF-8?q?=E2=80=94=20use=20git=20helper,=20safer=20branch=20name=20parsi?= =?UTF-8?q?ng?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit • Replace direct execFileSync in create.mjs with git.checkoutNew helper • Use regex capture group for branch name extraction in listTrackedBranches to handle branch names containing dots correctly Co-Authored-By: Claude Opus 4.6 (1M context) noreply@anthropic.com mailto:noreply@anthropic.com --- cli/commands/create.mjs | 3 +-- cli/git.mjs | 7 ++++++- 2 files changed, 7 insertions(+), 3 deletions(-) diff --git a/cli/commands/create.mjs b/cli/commands/create.mjs index f4c1562..f4c3a57 100644 --- a/cli/commands/create.mjs +++ b/cli/commands/create.mjs @@ -1,6 +1,5 @@ // st create — Create a new branch and auto-track it. -import { execFileSync } from 'node:child_process'; import * as git from '../git.mjs'; export function create(flags) { @@ -20,7 +19,7 @@ export function create(flags) { } // Create and checkout new branch - execFileSync('git', ['checkout', '-b', name], { encoding: 'utf-8', stdio: 'pipe' }); + git.checkoutNew(name); // Auto-track with current branch as parent git.setTrackedParent(name, current); diff --git a/cli/git.mjs b/cli/git.mjs index 8ea2042..cc4fda1 100644 --- a/cli/git.mjs +++ b/cli/git.mjs @@ -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 }); } @@ -127,7 +131,8 @@ export function listTrackedBranches() { const spaceIdx = line.indexOf(' '); const key = line.slice(0, spaceIdx); const parent = line.slice(spaceIdx + 1); - const branch = key.replace(/^branch\./, '').replace(/\.staqd-parent$/, ''); + const match = key.match(/^branch\.(.+)\.staqd-parent$/); + const branch = match ? match[1] : key; return { branch, parent }; }); }