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
1 change: 1 addition & 0 deletions CLAUDE.md
Original file line number Diff line number Diff line change
Expand Up @@ -155,6 +155,7 @@ These features exist specifically to make ez useable by AI agents:
| 0.2.18 | Add `-A`/`--all-files` for commit and push, preserve stale-base metadata until real restacks happen across sync/delete/merge, refresh trunk during `ez restack`, and warn after switching to a branch that is not restacked on latest main |
| 0.2.19 | Canonicalize `ez skill install` under `.agents/skills` with safe link-or-copy compatibility targets, improve worktree-awareness messaging and status output, and teach agents when to use `-A`/`-Am` for untracked files |
| 0.2.20 | Add non-interactive `ez merge --yes`, support `ez merge --stack` for linear stacks, and restore remote branch cleanup after REST-based merges |
| 0.2.24 | `ez sync`/`ez list` PR-status lookup uses one GraphQL request with aliased fields per branch instead of paginating every PR in the repo (122s → 1.8s on a 10k-PR repo); add local git-remote URL parser to skip the `gh repo view` round-trip when deriving owner/repo; `ez adopt` still uses the global PR scan because it needs the full PR graph. Adds `ez track [branch] [--parent <name>]` to bring a raw-git branch under ez management without rebasing — parent defaults to the closest tracked ancestor by merge-base, else trunk; updates `BranchNotInStack` error to hint at `ez track`. |

---

Expand Down
2 changes: 1 addition & 1 deletion Cargo.lock

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

2 changes: 1 addition & 1 deletion Cargo.toml
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
[package]
name = "ez-stack"
version = "0.2.23"
version = "0.2.24"
edition = "2024"
rust-version = "1.85"
description = "A CLI tool for managing stacked PRs with GitHub"
Expand Down
16 changes: 16 additions & 0 deletions src/cli.rs
Original file line number Diff line number Diff line change
Expand Up @@ -374,6 +374,22 @@ Examples:
git diff $(ez parent)...HEAD --stat")]
Parent,

/// Start tracking an existing local branch in the ez stack (pure metadata, no rebase)
#[command(after_help = "\
Examples:
ez track # track the current branch (infer parent)
ez track feat/orphan # track a specific branch (infer parent)
ez track --parent feat/base # set an explicit parent
ez track feat/orphan --parent feat/base")]
Track {
/// Branch to track (defaults to current branch)
branch: Option<String>,

/// Parent branch (defaults to closest tracked ancestor by merge-base, else trunk)
#[arg(long)]
parent: Option<String>,
},

/// Delete a branch (and its worktree if present), stop listeners on its dev port, and reparent its children
#[command(after_help = "\
Examples:
Expand Down
8 changes: 6 additions & 2 deletions src/cmd/list.rs
Original file line number Diff line number Diff line change
Expand Up @@ -121,11 +121,15 @@ pub fn run(json: bool) -> Result<()> {
})
.collect();

// One API call for all CI statuses (instead of N sequential gh calls).
// One API call for all PR statuses (instead of scanning every PR in the repo).
let has_any_branches = !branch_specs.is_empty();
let branch_names_for_prs: Vec<String> =
branch_specs.iter().map(|(name, ..)| name.clone()).collect();
let remote_for_prs = state.remote.clone();
let pr_handle = thread::spawn(move || {
if has_any_branches {
github::get_all_pr_statuses()
let refs: Vec<&str> = branch_names_for_prs.iter().map(String::as_str).collect();
github::get_pr_statuses_for(&remote_for_prs, &refs)
} else {
HashMap::new()
}
Expand Down
1 change: 1 addition & 0 deletions src/cmd/mod.rs
Original file line number Diff line number Diff line change
Expand Up @@ -29,5 +29,6 @@ pub mod skill;
pub mod status;
pub mod submit;
pub mod sync;
pub mod track;
pub mod update;
pub mod worktree;
3 changes: 2 additions & 1 deletion src/cmd/sync.rs
Original file line number Diff line number Diff line change
Expand Up @@ -196,7 +196,8 @@ fn run_sync_inner(force: bool) -> Result<()> {
let has_any_prs = !cleanup_candidates.is_empty();
let pr_statuses = if has_any_prs {
let sp = ui::spinner("Checking PR states...");
let statuses = github::get_all_pr_statuses();
let branch_refs: Vec<&str> = cleanup_candidates.iter().map(String::as_str).collect();
let statuses = github::get_pr_statuses_for(&state.remote, &branch_refs);
sp.finish_and_clear();
statuses
} else {
Expand Down
249 changes: 249 additions & 0 deletions src/cmd/track.rs
Original file line number Diff line number Diff line change
@@ -0,0 +1,249 @@
use anyhow::{Result, bail};

use crate::error::EzError;
use crate::git;
use crate::stack::StackState;
use crate::ui;

/// Start tracking an existing local branch in the ez stack.
///
/// Pure metadata write — no rebase, no network, no branch creation. Use this
/// when you (or a tool) created a branch with raw `git checkout -b` and want
/// ez to take over future commits, restacks, and pushes.
///
/// - `branch` defaults to the current branch.
/// - `parent` is inferred via merge-base if not given: among trunk and the
/// already-tracked branches, the one whose merge-base with the target is
/// the strictly deepest descendant wins. Defaults to trunk if no other
/// branch is a closer ancestor.
/// - `parent_head` is set to the merge-base of (parent, branch), so the next
/// `ez sync`/`ez restack` correctly rebases branch onto current parent tip.
pub fn run(branch: Option<String>, parent: Option<String>) -> Result<()> {
let mut state = StackState::load()?;

let target = match branch {
Some(b) => b,
None => git::current_branch()?,
};

if state.is_trunk(&target) {
bail!(EzError::UserMessage(format!(
"`{target}` is the trunk branch and cannot be tracked as a stacked branch\n → Run `ez create <name>` to start a new stack on top of trunk"
)));
}

if !git::branch_exists(&target) {
bail!(EzError::UserMessage(format!(
"branch `{target}` does not exist locally\n → Run `git branch` to see local branches, or `ez create {target}` to create it"
)));
}

if state.is_managed(&target) {
let existing = state.get_branch(&target)?;
bail!(EzError::UserMessage(format!(
"branch `{target}` is already tracked (parent: `{}`)\n → Run `ez move --parent <name>` to reparent, or `ez log` to inspect the stack",
existing.parent
)));
}

let parent_name = match parent {
Some(p) => {
if p == target {
bail!(EzError::UserMessage(format!(
"cannot set `{target}` as its own parent"
)));
}
if p != state.trunk && !state.is_managed(&p) {
bail!(EzError::UserMessage(format!(
"parent `{p}` is not the trunk or a tracked branch\n → Run `ez list` to see tracked branches, or `ez track {p}` first to track it"
)));
}
if !git::branch_exists(&p) {
bail!(EzError::UserMessage(format!(
"parent `{p}` does not exist locally"
)));
}
p
}
None => infer_parent(&target, &state)?,
};

let parent_head = git::merge_base(&parent_name, &target).map_err(|_| {
EzError::UserMessage(format!(
"`{target}` and `{parent_name}` have no common history\n → Pass `--parent <name>` explicitly"
))
})?;

let ahead = git::rev_list_count(&parent_head, &target).unwrap_or(0);

state.add_branch(&target, &parent_name, &parent_head, None, None);
state.save()?;

let short = &parent_head[..parent_head.len().min(7)];
ui::success(&format!(
"Tracking `{target}` on `{parent_name}` (parent_head: {short})"
));
if ahead > 0 {
ui::info(&format!(
"`{target}` is {ahead} commit(s) ahead of `{parent_name}`"
));
} else {
ui::info(&format!(
"`{target}` has no commits beyond `{parent_name}` yet"
));
}
ui::hint(&format!(
"Run `ez log` to see the stack, or `ez restack` if `{parent_name}` has new commits"
));

ui::receipt(&serde_json::json!({
"cmd": "track",
"branch": target,
"parent": parent_name,
"parent_head": short,
"commits_ahead": ahead,
}));

Ok(())
}

/// Pick the closest tracked ancestor of `branch` as its parent.
///
/// Trunk is always a fallback. Among trunk + tracked branches, the candidate
/// whose merge-base with `branch` is the strictly deepest descendant wins:
/// that's the candidate closest to `branch` in commit graph terms. Ties go to
/// the existing best (trunk-first iteration), so trunk wins only when no
/// tracked branch is a strict descendant.
fn infer_parent(branch: &str, state: &StackState) -> Result<String> {
let trunk_mb = git::merge_base(&state.trunk, branch).map_err(|_| {
EzError::UserMessage(format!(
"could not find a merge-base between `{branch}` and trunk `{}`\n → Pass `--parent <name>` explicitly",
state.trunk
))
})?;
let mut best = (state.trunk.clone(), trunk_mb);

for candidate in state.branches.keys() {
if candidate == branch {
continue;
}
let Ok(mb) = git::merge_base(candidate, branch) else {
continue;
};
if mb != best.1 && git::is_ancestor(&best.1, &mb) {
best = (candidate.clone(), mb);
}
}

Ok(best.0)
}

#[cfg(test)]
mod tests {
use std::collections::HashMap;

use super::*;

/// Pure version of the parent-selection rule, decoupled from git I/O so we
/// can test the tie-breaking and descendant-preference logic directly.
///
/// `merge_bases` maps candidate branch → merge-base SHA with the target.
/// `descendants` maps (ancestor SHA, descendant SHA) → true to model
/// `git::is_ancestor`. The function reproduces `infer_parent`'s rule:
/// start with trunk, replace it iff another candidate's merge-base is a
/// strict descendant of the current best's merge-base.
fn select_best_parent(
trunk: &str,
candidates: &[&str],
merge_bases: &HashMap<&str, &str>,
descendants: &HashMap<(&str, &str), bool>,
) -> String {
let mut best_branch = trunk.to_string();
let mut best_mb = *merge_bases
.get(trunk)
.expect("trunk must have a merge-base");

for c in candidates {
if *c == trunk {
continue;
}
let Some(mb) = merge_bases.get(*c) else {
continue;
};
let is_strict_descendant =
*mb != best_mb && *descendants.get(&(best_mb, *mb)).unwrap_or(&false);
if is_strict_descendant {
best_branch = c.to_string();
best_mb = *mb;
}
}
best_branch
}

#[test]
fn defaults_to_trunk_when_no_other_branch_is_a_closer_ancestor() {
// Scenario: branch B was branched off trunk. Tracked branch A is a
// sibling — its merge-base with B is also at trunk's tip. Trunk wins.
let mut mb = HashMap::new();
mb.insert("main", "sha_root");
mb.insert("feat/a", "sha_root");
// sha_root is not a strict descendant of itself.
let descendants = HashMap::new();

let got = select_best_parent("main", &["main", "feat/a"], &mb, &descendants);
assert_eq!(got, "main");
}

#[test]
fn picks_tracked_branch_when_its_merge_base_is_strictly_deeper() {
// Scenario: branch B was branched off feat/a, which itself was
// branched off main. merge-base(main, B) = sha_root; merge-base(feat/a, B) = sha_a.
// sha_a is a descendant of sha_root — feat/a wins.
let mut mb = HashMap::new();
mb.insert("main", "sha_root");
mb.insert("feat/a", "sha_a");

let mut descendants = HashMap::new();
descendants.insert(("sha_root", "sha_a"), true);

let got = select_best_parent("main", &["main", "feat/a"], &mb, &descendants);
assert_eq!(got, "feat/a");
}

#[test]
fn picks_deepest_when_multiple_tracked_branches_overlap() {
// Chain: main → feat/a → feat/b → B. Both feat/a and feat/b are
// ancestors of B; feat/b is deeper. feat/b wins.
let mut mb = HashMap::new();
mb.insert("main", "sha_root");
mb.insert("feat/a", "sha_a");
mb.insert("feat/b", "sha_b");

let mut descendants = HashMap::new();
descendants.insert(("sha_root", "sha_a"), true);
descendants.insert(("sha_root", "sha_b"), true);
descendants.insert(("sha_a", "sha_b"), true);

// Iteration order over HashMap keys is non-deterministic; both orders
// must produce the same winner.
let got1 = select_best_parent("main", &["main", "feat/a", "feat/b"], &mb, &descendants);
let got2 = select_best_parent("main", &["main", "feat/b", "feat/a"], &mb, &descendants);
assert_eq!(got1, "feat/b");
assert_eq!(got2, "feat/b");
}

#[test]
fn ignores_sibling_branches_whose_merge_base_is_unrelated_to_target() {
// feat/a is a sibling of B, branched off main at the same SHA.
// feat/c is in a different lineage — its merge-base is at trunk too.
// Neither is a strict descendant; trunk wins.
let mut mb = HashMap::new();
mb.insert("main", "sha_root");
mb.insert("feat/a", "sha_root");
mb.insert("feat/c", "sha_root");
let descendants = HashMap::new();

let got = select_best_parent("main", &["main", "feat/a", "feat/c"], &mb, &descendants);
assert_eq!(got, "main");
}
}
7 changes: 5 additions & 2 deletions src/error.rs
Original file line number Diff line number Diff line change
Expand Up @@ -17,7 +17,9 @@ pub enum EzError {
#[error("currently on trunk branch — create a stacked branch first with `ez create <name>`")]
OnTrunk,

#[error("branch `{0}` not found in stack metadata\n → Run `ez log` to see tracked branches")]
#[error(
"branch `{0}` is not tracked by ez\n → Run `ez track` to start tracking it, or `ez log` to see tracked branches"
)]
BranchNotInStack(String),

#[error("branch `{0}` already exists — use `ez checkout {0}` to switch to it")]
Expand Down Expand Up @@ -74,7 +76,8 @@ mod tests {
assert!(
EzError::BranchNotInStack("feat/x".into())
.to_string()
.contains("stack metadata")
.contains("ez track"),
"BranchNotInStack should hint at `ez track`"
);
assert!(
EzError::NothingToCommit
Expand Down
15 changes: 15 additions & 0 deletions src/git.rs
Original file line number Diff line number Diff line change
Expand Up @@ -442,6 +442,13 @@ pub fn merge_base(a: &str, b: &str) -> Result<String> {
run_git(&["merge-base", a, b])
}

/// Count commits reachable from `tip` that are not reachable from `base`
/// (i.e. `git rev-list --count base..tip`). Returns 0 on parse or git error.
pub fn rev_list_count(base: &str, tip: &str) -> Result<u64> {
let out = run_git(&["rev-list", "--count", &format!("{base}..{tip}")])?;
Ok(out.trim().parse().unwrap_or(0))
}

/// Returns true if `ancestor` is reachable from `descendant` (i.e. is an ancestor of it).
/// Returns false if not, or if either ref does not exist.
pub fn is_ancestor(ancestor: &str, descendant: &str) -> bool {
Expand Down Expand Up @@ -500,6 +507,14 @@ pub fn remote_branch_exists(remote: &str, branch: &str) -> bool {
.unwrap_or(false)
}

/// Read the configured URL for a remote (`git remote get-url <remote>`).
///
/// Returns the URL as configured in `.git/config`. Used to derive the
/// GitHub owner/repo without a network round-trip.
pub fn remote_url(remote: &str) -> Result<String> {
run_git(&["remote", "get-url", remote])
}

pub fn branch_list() -> Result<Vec<String>> {
let output = run_git(&["branch", "--format=%(refname:short)"])?;
Ok(output.lines().map(|s| s.to_string()).collect())
Expand Down
Loading
Loading