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
3 changes: 3 additions & 0 deletions src/cli.rs
Original file line number Diff line number Diff line change
@@ -1,3 +1,5 @@
//! CLI argument definitions and top-level command dispatch.

use clap::{Parser, Subcommand};
use std::process::ExitCode;

Expand Down Expand Up @@ -43,6 +45,7 @@ enum Commands {
Push,
}

/// Parse CLI arguments, run preflight checks, and dispatch to a command handler.
pub fn run() -> ExitCode {
let cli = Cli::parse();

Expand Down
11 changes: 11 additions & 0 deletions src/commands.rs
Original file line number Diff line number Diff line change
@@ -1,3 +1,5 @@
//! Command implementations behind the clap definitions in `cli`.

use std::process::ExitCode;

use crate::env;
Expand All @@ -6,6 +8,7 @@ use crate::gitops;
use crate::stack;
use crate::sync_state::{self, LastSyncPlan, PushState, SyncState};

/// Print the detected stack, its PR state, and any local follow-up actions.
pub(crate) fn run_status(preflight: &env::PreflightContext) -> ExitCode {
if preflight.current_branch == preflight.default_branch {
println!(
Expand Down Expand Up @@ -108,6 +111,7 @@ pub(crate) fn run_status(preflight: &env::PreflightContext) -> ExitCode {
ExitCode::SUCCESS
}

/// Create the next branch in the stack and bootstrap the current branch PR when needed.
pub(crate) fn run_new(preflight: &env::PreflightContext, new_branch: &str) -> ExitCode {
let current_branch = &preflight.current_branch;
let starting_from_default = current_branch == &preflight.default_branch;
Expand Down Expand Up @@ -244,6 +248,7 @@ pub(crate) fn run_new(preflight: &env::PreflightContext, new_branch: &str) -> Ex
ExitCode::SUCCESS
}

/// Discover the intended PR base for a branch by looking for its parent in the open stack.
fn discover_parent_base(branch: &str, default_branch: &str) -> Result<String, String> {
let prs = github::list_open_prs().map_err(|message| {
format!(
Expand Down Expand Up @@ -277,6 +282,7 @@ fn discover_parent_base(branch: &str, default_branch: &str) -> Result<String, St
Ok(best.unwrap_or(default_branch).to_string())
}

/// Create a pull request for the current branch if one does not already exist.
pub(crate) fn run_submit(
preflight: &env::PreflightContext,
base_override: Option<&str>,
Expand Down Expand Up @@ -355,6 +361,10 @@ pub(crate) fn run_submit(
ExitCode::SUCCESS
}

/// Rebase the current stacked branch and its descendants onto the correct bases.
///
/// This command supports resumable operation state via `sync_state`, including
/// explicit `--continue` and `--reset` flows after a failed rebase.
pub(crate) fn run_sync(
preflight: &env::PreflightContext,
continue_sync: bool,
Expand Down Expand Up @@ -619,6 +629,7 @@ pub(crate) fn run_sync(
ExitCode::SUCCESS
}

/// Push rewritten stack branches and retarget any affected pull requests.
pub(crate) fn run_push(preflight: &env::PreflightContext) -> ExitCode {
if let Err(message) = gitops::fetch_origin() {
eprintln!("error: {message}");
Expand Down
10 changes: 10 additions & 0 deletions src/env.rs
Original file line number Diff line number Diff line change
@@ -1,11 +1,21 @@
//! Repository and toolchain preflight checks required before running commands.

use std::process::Command;

/// Repository context gathered during preflight and reused by command handlers.
#[derive(Debug, Clone)]
pub struct PreflightContext {
/// The currently checked-out local branch.
pub current_branch: String,
/// The repository's default branch as reported by GitHub.
pub default_branch: String,
}

/// Validate the local repository and discover branch context needed by `stck`.
///
/// This checks that `git` and `gh` are installed, GitHub authentication is
/// available, the repository has an `origin` remote, the current HEAD is on a
/// branch, the working tree is clean, and the default branch can be discovered.
pub fn run_preflight() -> Result<PreflightContext, String> {
ensure_command_available("git")?;
ensure_command_available("gh")?;
Expand Down
28 changes: 28 additions & 0 deletions src/github.rs
Original file line number Diff line number Diff line change
@@ -1,13 +1,19 @@
//! GitHub pull request discovery and mutation helpers backed by the `gh` CLI.

use serde::Deserialize;
use std::process::Command;

use crate::util::with_stderr;

/// The GitHub state of a pull request as returned by `gh`.
#[derive(Debug, Clone, Copy, PartialEq, Eq, Deserialize)]
#[serde(rename_all = "SCREAMING_SNAKE_CASE")]
pub enum PrState {
/// The pull request is still open.
Open,
/// The pull request has been merged.
Merged,
/// The pull request is closed without being merged.
Closed,
}

Expand All @@ -21,16 +27,26 @@ impl std::fmt::Display for PrState {
}
}

/// Minimal pull request metadata needed to reason about a linear stack.
#[derive(Debug, Clone, Deserialize)]
pub struct PullRequest {
/// The GitHub pull request number.
pub number: u64,
/// The branch name used as the PR head.
#[serde(rename = "headRefName")]
pub head_ref_name: String,
/// The branch name currently targeted as the PR base.
#[serde(rename = "baseRefName")]
pub base_ref_name: String,
/// The current GitHub state of the PR.
pub state: PrState,
}

/// Discover the full linear stack surrounding `current_branch`.
///
/// The returned list is ordered from the stack root to the highest descendant
/// branch. The function fails if any parent PR is missing, if multiple open
/// children exist for a branch, or if the PR graph forms a cycle.
pub fn discover_linear_stack(
current_branch: &str,
default_branch: &str,
Expand Down Expand Up @@ -100,6 +116,7 @@ pub fn discover_linear_stack(
Ok(stack)
}

/// Change the GitHub base branch for the PR whose head matches `branch`.
pub fn retarget_pr_base(branch: &str, new_base: &str) -> Result<(), String> {
let output = Command::new("gh")
.args(["pr", "edit", branch, "--base", new_base])
Expand All @@ -118,6 +135,10 @@ pub fn retarget_pr_base(branch: &str, new_base: &str) -> Result<(), String> {
}
}

/// Return whether a pull request already exists for `branch`.
///
/// "No pull request found" responses are treated as `Ok(false)`. Other `gh`
/// failures are surfaced as actionable errors.
pub fn pr_exists_for_head(branch: &str) -> Result<bool, String> {
let output = Command::new("gh")
.args(["pr", "view", branch, "--json", "number"])
Expand Down Expand Up @@ -145,6 +166,7 @@ pub fn pr_exists_for_head(branch: &str) -> Result<bool, String> {
}
}

/// Create a pull request with the given base, head, and title.
pub fn create_pr(base: &str, head: &str, title: &str) -> Result<(), String> {
let output = Command::new("gh")
.args([
Expand All @@ -163,6 +185,11 @@ pub fn create_pr(base: &str, head: &str, title: &str) -> Result<(), String> {
}
}

/// List open pull requests visible to the current repository.
///
/// This currently relies on `gh pr list --limit 100`, so callers should treat
/// it as a best-effort discovery source rather than a complete repository-wide
/// index in very large repositories.
pub fn list_open_prs() -> Result<Vec<PullRequest>, String> {
let output = Command::new("gh")
.args([
Expand Down Expand Up @@ -251,6 +278,7 @@ fn parse_pull_requests_json(bytes: &[u8]) -> Result<Vec<PullRequest>, String> {
.map_err(|_| "failed to parse PR metadata from GitHub CLI output".to_string())
}

/// Test-only stack builder that mirrors `discover_linear_stack` without shelling out.
#[cfg(test)]
pub fn build_linear_stack(
prs: &[PullRequest],
Expand Down
33 changes: 33 additions & 0 deletions src/gitops.rs
Original file line number Diff line number Diff line change
@@ -1,8 +1,11 @@
//! Git subprocess helpers used by stack planning and command execution.

use std::process::{Command, Stdio};
use std::{env, path::PathBuf};

use crate::util::with_stderr;

/// Fetch updated refs from the `origin` remote.
pub fn fetch_origin() -> Result<(), String> {
let output = Command::new("git")
.args(["fetch", "origin"])
Expand All @@ -21,6 +24,10 @@ pub fn fetch_origin() -> Result<(), String> {
}
}

/// Return whether the local branch head differs from `origin/<branch>`.
///
/// Missing remote refs are treated as needing a push so newly created branches
/// show up as actionable.
pub fn branch_needs_push(branch: &str) -> Result<bool, String> {
let local_ref = format!("refs/heads/{branch}");
let remote_ref = format!("refs/remotes/origin/{branch}");
Expand All @@ -34,10 +41,16 @@ pub fn branch_needs_push(branch: &str) -> Result<bool, String> {
Ok(local_sha != remote_sha)
}

/// Resolve a git reference to its full object SHA.
pub fn resolve_ref(reference: &str) -> Result<String, String> {
rev_parse(reference)
}

/// Resolve the fork point to use as the old base for `git rebase --onto`.
///
/// This prefers the merge-base between `branch` and `base_branch` so sync can
/// recover from squash merges and rewritten ancestry. If no merge-base can be
/// found, the resolved base branch ref is used as a fallback.
pub fn resolve_old_base_for_rebase(base_branch: &str, branch: &str) -> Result<String, String> {
// Try merge-base between the branch and the old base ref to find the true
// fork point. This handles squash-merge and rewritten-ancestry scenarios
Expand Down Expand Up @@ -90,6 +103,7 @@ fn merge_base(ref_a: &str, ref_b: &str) -> Result<String, String> {
}
}

/// Return the absolute path to the repository's `.git` directory.
pub fn git_dir() -> Result<PathBuf, String> {
let output = Command::new("git")
.args(["rev-parse", "--git-dir"])
Expand All @@ -114,6 +128,7 @@ pub fn git_dir() -> Result<PathBuf, String> {
}
}

/// Return whether `ancestor_ref` is an ancestor of `descendant_ref`.
pub fn is_ancestor(ancestor_ref: &str, descendant_ref: &str) -> Result<bool, String> {
let output = Command::new("git")
.args(["merge-base", "--is-ancestor", ancestor_ref, descendant_ref])
Expand All @@ -129,11 +144,16 @@ pub fn is_ancestor(ancestor_ref: &str, descendant_ref: &str) -> Result<bool, Str
}
}

/// Detect whether a git rebase is currently in progress in this repository.
pub fn rebase_in_progress() -> Result<bool, String> {
let git_dir = git_dir()?;
Ok(git_dir.join("rebase-merge").exists() || git_dir.join("rebase-apply").exists())
}

/// Return whether `branch` is behind the fetched remote default branch.
///
/// This uses `origin/<default_branch>` and therefore expects callers to fetch
/// before relying on the result.
pub fn branch_needs_sync_with_default(default_branch: &str, branch: &str) -> Result<bool, String> {
let default_ref = format!("refs/remotes/origin/{default_branch}");
let branch_ref = format!("refs/heads/{branch}");
Expand All @@ -152,6 +172,10 @@ pub fn branch_needs_sync_with_default(default_branch: &str, branch: &str) -> Res
}
}

/// Rebase `branch` onto `new_base`, using `old_base` as the fork point.
///
/// Standard git rebase progress and conflict output is inherited directly so
/// the user can continue or abort with native git commands when needed.
pub fn rebase_onto(new_base: &str, old_base: &str, branch: &str) -> Result<(), String> {
let status = Command::new("git")
.args(["rebase", "--onto", new_base, old_base, branch])
Expand All @@ -168,6 +192,7 @@ pub fn rebase_onto(new_base: &str, old_base: &str, branch: &str) -> Result<(), S
}
}

/// Push `branch` to `origin` with `--force-with-lease`.
pub fn push_force_with_lease(branch: &str) -> Result<(), String> {
let status = Command::new("git")
.args(["push", "--force-with-lease", "origin", branch])
Expand All @@ -184,6 +209,7 @@ pub fn push_force_with_lease(branch: &str) -> Result<(), String> {
}
}

/// Return whether `branch` has an upstream tracking branch configured.
pub fn branch_has_upstream(branch: &str) -> Result<bool, String> {
let output = Command::new("git")
.args([
Expand All @@ -202,14 +228,17 @@ pub fn branch_has_upstream(branch: &str) -> Result<bool, String> {
}
}

/// Return whether a local branch named `branch` exists.
pub fn local_branch_exists(branch: &str) -> Result<bool, String> {
ref_exists(&format!("refs/heads/{branch}"))
}

/// Return whether `origin/<branch>` exists locally.
pub fn remote_branch_exists(branch: &str) -> Result<bool, String> {
ref_exists(&format!("refs/remotes/origin/{branch}"))
}

/// Push `branch` to `origin` and configure it as the upstream branch.
pub fn push_set_upstream(branch: &str) -> Result<(), String> {
let output = Command::new("git")
.args(["push", "-u", "origin", branch])
Expand All @@ -226,6 +255,7 @@ pub fn push_set_upstream(branch: &str) -> Result<(), String> {
}
}

/// Create and check out a new local branch.
pub fn checkout_new_branch(branch: &str) -> Result<(), String> {
let output = Command::new("git")
.args(["checkout", "-b", branch])
Expand All @@ -246,6 +276,7 @@ pub fn checkout_new_branch(branch: &str) -> Result<(), String> {
}
}

/// Check out an existing local branch.
pub fn checkout_branch(branch: &str) -> Result<(), String> {
let output = Command::new("git")
.args(["checkout", branch])
Expand All @@ -262,6 +293,7 @@ pub fn checkout_branch(branch: &str) -> Result<(), String> {
}
}

/// Return whether `head` contains commits not present on `base`.
pub fn has_commits_between(base: &str, head: &str) -> Result<bool, String> {
let output = Command::new("git")
.args([
Expand Down Expand Up @@ -303,6 +335,7 @@ fn rev_parse(reference: &str) -> Result<String, String> {
}
}

/// Return whether `name` is accepted by `git check-ref-format --allow-onelevel`.
pub fn is_valid_branch_name(name: &str) -> Result<bool, String> {
let output = Command::new("git")
.args(["check-ref-format", "--allow-onelevel", name])
Expand Down
10 changes: 10 additions & 0 deletions src/lib.rs
Original file line number Diff line number Diff line change
@@ -1,4 +1,13 @@
//! Core implementation for `stck`, a CLI for working with stacked GitHub pull requests.
//!
//! The crate intentionally stays close to native `git` and `gh` workflows. It keeps
//! CLI parsing, subprocess-backed integrations, stack planning, and resumable
//! operation state separate so the user-facing commands remain predictable and
//! testable.

#![forbid(unsafe_code)]
#![deny(missing_docs)]
#![deny(rustdoc::missing_crate_level_docs)]

mod cli;
mod commands;
Expand All @@ -11,6 +20,7 @@ mod util;

use std::process::ExitCode;

/// Parse CLI arguments, run preflight checks, and execute the selected command.
pub fn run() -> ExitCode {
cli::run()
}
2 changes: 2 additions & 0 deletions src/main.rs
Original file line number Diff line number Diff line change
@@ -1,3 +1,5 @@
//! Binary entrypoint for the `stck` CLI.

#![forbid(unsafe_code)]

use std::process::ExitCode;
Expand Down
Loading
Loading