diff --git a/src/cli.rs b/src/cli.rs index a616ed0..4f9fa88 100644 --- a/src/cli.rs +++ b/src/cli.rs @@ -1,3 +1,5 @@ +//! CLI argument definitions and top-level command dispatch. + use clap::{Parser, Subcommand}; use std::process::ExitCode; @@ -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(); diff --git a/src/commands.rs b/src/commands.rs index ddb98bf..37cabc2 100644 --- a/src/commands.rs +++ b/src/commands.rs @@ -1,3 +1,5 @@ +//! Command implementations behind the clap definitions in `cli`. + use std::process::ExitCode; use crate::env; @@ -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!( @@ -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; @@ -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 { let prs = github::list_open_prs().map_err(|message| { format!( @@ -277,6 +282,7 @@ fn discover_parent_base(branch: &str, default_branch: &str) -> Result, @@ -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, @@ -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}"); diff --git a/src/env.rs b/src/env.rs index c36a589..ea1e94b 100644 --- a/src/env.rs +++ b/src/env.rs @@ -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 { ensure_command_available("git")?; ensure_command_available("gh")?; diff --git a/src/github.rs b/src/github.rs index 68035e2..9b03b1c 100644 --- a/src/github.rs +++ b/src/github.rs @@ -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, } @@ -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, @@ -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]) @@ -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 { let output = Command::new("gh") .args(["pr", "view", branch, "--json", "number"]) @@ -145,6 +166,7 @@ pub fn pr_exists_for_head(branch: &str) -> Result { } } +/// 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([ @@ -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, String> { let output = Command::new("gh") .args([ @@ -251,6 +278,7 @@ fn parse_pull_requests_json(bytes: &[u8]) -> Result, 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], diff --git a/src/gitops.rs b/src/gitops.rs index 674b987..f5ca683 100644 --- a/src/gitops.rs +++ b/src/gitops.rs @@ -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"]) @@ -21,6 +24,10 @@ pub fn fetch_origin() -> Result<(), String> { } } +/// Return whether the local branch head differs from `origin/`. +/// +/// 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 { let local_ref = format!("refs/heads/{branch}"); let remote_ref = format!("refs/remotes/origin/{branch}"); @@ -34,10 +41,16 @@ pub fn branch_needs_push(branch: &str) -> Result { Ok(local_sha != remote_sha) } +/// Resolve a git reference to its full object SHA. pub fn resolve_ref(reference: &str) -> Result { 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 { // 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 @@ -90,6 +103,7 @@ fn merge_base(ref_a: &str, ref_b: &str) -> Result { } } +/// Return the absolute path to the repository's `.git` directory. pub fn git_dir() -> Result { let output = Command::new("git") .args(["rev-parse", "--git-dir"]) @@ -114,6 +128,7 @@ pub fn git_dir() -> Result { } } +/// Return whether `ancestor_ref` is an ancestor of `descendant_ref`. pub fn is_ancestor(ancestor_ref: &str, descendant_ref: &str) -> Result { let output = Command::new("git") .args(["merge-base", "--is-ancestor", ancestor_ref, descendant_ref]) @@ -129,11 +144,16 @@ pub fn is_ancestor(ancestor_ref: &str, descendant_ref: &str) -> Result Result { 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/` and therefore expects callers to fetch +/// before relying on the result. pub fn branch_needs_sync_with_default(default_branch: &str, branch: &str) -> Result { let default_ref = format!("refs/remotes/origin/{default_branch}"); let branch_ref = format!("refs/heads/{branch}"); @@ -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]) @@ -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]) @@ -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 { let output = Command::new("git") .args([ @@ -202,14 +228,17 @@ pub fn branch_has_upstream(branch: &str) -> Result { } } +/// Return whether a local branch named `branch` exists. pub fn local_branch_exists(branch: &str) -> Result { ref_exists(&format!("refs/heads/{branch}")) } +/// Return whether `origin/` exists locally. pub fn remote_branch_exists(branch: &str) -> Result { 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]) @@ -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]) @@ -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]) @@ -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 { let output = Command::new("git") .args([ @@ -303,6 +335,7 @@ fn rev_parse(reference: &str) -> Result { } } +/// Return whether `name` is accepted by `git check-ref-format --allow-onelevel`. pub fn is_valid_branch_name(name: &str) -> Result { let output = Command::new("git") .args(["check-ref-format", "--allow-onelevel", name]) diff --git a/src/lib.rs b/src/lib.rs index 9950d4e..8ce7b85 100644 --- a/src/lib.rs +++ b/src/lib.rs @@ -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; @@ -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() } diff --git a/src/main.rs b/src/main.rs index 7d42431..274bca8 100644 --- a/src/main.rs +++ b/src/main.rs @@ -1,3 +1,5 @@ +//! Binary entrypoint for the `stck` CLI. + #![forbid(unsafe_code)] use std::process::ExitCode; diff --git a/src/stack.rs b/src/stack.rs index 698a9ee..147d289 100644 --- a/src/stack.rs +++ b/src/stack.rs @@ -1,42 +1,70 @@ +//! Pure stack-planning helpers derived from GitHub PR metadata. + use crate::github::{PrState, PullRequest}; use serde::{Deserialize, Serialize}; +/// Per-branch status information rendered by `stck status`. #[derive(Debug, Clone, PartialEq, Eq)] pub struct StatusLine { + /// The PR head branch name. pub branch: String, + /// The GitHub pull request number. pub number: u64, + /// The current GitHub state of the PR. pub state: PrState, + /// The PR's current base branch name. pub base: String, + /// The PR's current head branch name. pub head: String, + /// Derived status flags such as `needs_sync` or `base_mismatch`. pub flags: Vec<&'static str>, } +/// Aggregated counts for actionable status flags across a stack. #[derive(Debug, Clone, PartialEq, Eq)] pub struct StatusSummary { + /// Number of branches that require a sync/rebase operation. pub needs_sync: usize, + /// Number of branches whose local head differs from `origin`. pub needs_push: usize, + /// Number of branches whose PR base does not match the expected stack parent. pub base_mismatch: usize, } +/// Full status output derived from a discovered stack. #[derive(Debug, Clone, PartialEq, Eq)] pub struct StatusReport { + /// Per-branch status lines in stack order. pub lines: Vec, + /// Aggregate flag counts for the same stack. pub summary: StatusSummary, } +/// A single rebase operation required to restack a branch locally. #[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] pub struct SyncStep { + /// The branch that should be rebased. pub branch: String, + /// The previous base ref used to identify the rebase range. pub old_base_ref: String, + /// The branch or ref that the branch should end up based on. pub new_base_ref: String, } +/// A single PR base retarget operation required after pushing rewritten branches. #[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] pub struct RetargetStep { + /// The PR head branch whose base should be updated. pub branch: String, + /// The desired PR base branch after the stack has been rewritten. pub new_base_ref: String, } +/// Build the status view for a discovered stack. +/// +/// This function only reasons about GitHub metadata and stack shape. Local +/// branch divergence from `origin` and default-branch ancestry checks are added +/// by higher-level command code. pub fn build_status_report(stack: &[PullRequest], default_branch: &str) -> StatusReport { let mut lines = Vec::with_capacity(stack.len()); let mut needs_sync = 0usize; @@ -91,6 +119,10 @@ pub fn build_status_report(stack: &[PullRequest], default_branch: &str) -> Statu } } +/// Return the first open branch whose base already points at the default branch. +/// +/// When present, this branch is the one that should be checked for default +/// branch drift against `origin/`. pub fn first_open_branch_rooted_on_default<'a>( stack: &'a [PullRequest], default_branch: &str, @@ -104,10 +136,16 @@ pub fn first_open_branch_rooted_on_default<'a>( } } +/// Build the default sync plan for a stack. pub fn build_sync_plan(stack: &[PullRequest], default_branch: &str) -> Vec { build_sync_plan_with_options(stack, default_branch, false) } +/// Build the sequence of rebase steps needed to restore a linear open stack. +/// +/// Merged PRs are skipped when choosing the effective parent chain. Once an +/// open branch needs rewriting, every later open descendant is also rewritten +/// so the local branch ancestry stays consistent with the intended stack order. pub fn build_sync_plan_with_options( stack: &[PullRequest], default_branch: &str, @@ -146,6 +184,7 @@ pub fn build_sync_plan_with_options( steps } +/// List the open PR branches that should be pushed during `stck push`. pub fn build_push_branches(stack: &[PullRequest]) -> Vec { stack .iter() @@ -154,6 +193,7 @@ pub fn build_push_branches(stack: &[PullRequest]) -> Vec { .collect() } +/// Convert the sync plan for a stack into the PR retarget operations needed after push. pub fn build_push_retargets(stack: &[PullRequest], default_branch: &str) -> Vec { build_sync_plan(stack, default_branch) .into_iter() @@ -164,6 +204,10 @@ pub fn build_push_retargets(stack: &[PullRequest], default_branch: &str) -> Vec< .collect() } +/// Remove retarget steps that are already satisfied by the current PR metadata. +/// +/// Missing PRs are kept in the result so the caller can surface the mismatch +/// instead of silently discarding it. pub fn filter_pending_retargets( retargets: Vec, stack: &[PullRequest], diff --git a/src/sync_state.rs b/src/sync_state.rs index 5a74f97..f52aa15 100644 --- a/src/sync_state.rs +++ b/src/sync_state.rs @@ -1,29 +1,44 @@ +//! Persistence for resumable `sync` and `push` workflows under `.git/stck/`. + use crate::gitops; use crate::stack::{RetargetStep, SyncStep}; use serde::{Deserialize, Serialize}; use std::fs; use std::path::PathBuf; +/// Saved progress for an in-flight `stck sync` operation. #[derive(Debug, Clone, Serialize, Deserialize)] pub struct SyncState { + /// The full ordered list of rebase steps for the current sync run. pub steps: Vec, + /// Number of sync steps that completed successfully. pub completed_steps: usize, + /// Index of the step that most recently failed, if any. pub failed_step: Option, + /// Recorded branch head after the failed step began, used to validate resume behavior. #[serde(default)] pub failed_step_branch_head: Option, } +/// Saved progress for an in-flight `stck push` operation. #[derive(Debug, Clone, Serialize, Deserialize)] pub struct PushState { + /// Branches that still need to be pushed, in execution order. pub push_branches: Vec, + /// Number of branch pushes that completed successfully. pub completed_pushes: usize, + /// PR base retarget operations to run after the branch pushes succeed. pub retargets: Vec, + /// Number of retarget operations that completed successfully. pub completed_retargets: usize, } +/// Cached retarget plan produced by the most recent successful sync run. #[derive(Debug, Clone, Serialize, Deserialize)] pub struct LastSyncPlan { + /// The default branch that the cached plan was built against. pub default_branch: String, + /// PR retarget operations implied by the sync plan. pub retargets: Vec, } @@ -34,6 +49,10 @@ enum LastPlanState { Push(PushState), } +/// Load the current saved sync state, if one exists. +/// +/// If a push state file is present instead, this returns an error because sync +/// and push state share the same persistence slot. pub fn load_sync() -> Result, String> { let path = state_file_path()?; if !path.exists() { @@ -50,10 +69,15 @@ pub fn load_sync() -> Result, String> { } } +/// Persist sync progress for later `stck sync --continue` or `--reset` flows. pub fn save_sync(state: &SyncState) -> Result<(), String> { save_raw_state(LastPlanState::Sync(state.clone())) } +/// Load the current saved push state, if one exists. +/// +/// If a sync state file is present instead, this returns an error because push +/// cannot proceed until the sync workflow is resolved. pub fn load_push() -> Result, String> { let path = state_file_path()?; if !path.exists() { @@ -70,10 +94,12 @@ pub fn load_push() -> Result, String> { } } +/// Persist push progress for later resume attempts. pub fn save_push(state: &PushState) -> Result<(), String> { save_raw_state(LastPlanState::Push(state.clone())) } +/// Remove any saved sync or push state file. pub fn clear() -> Result<(), String> { let path = state_file_path()?; if !path.exists() { @@ -83,6 +109,7 @@ pub fn clear() -> Result<(), String> { fs::remove_file(&path).map_err(|_| format!("failed to remove sync state at {}", path.display())) } +/// Load the cached retarget plan from the last successful sync run. pub fn load_last_sync_plan() -> Result, String> { let path = last_sync_plan_path()?; if !path.exists() { @@ -95,6 +122,7 @@ pub fn load_last_sync_plan() -> Result, String> { Ok(Some(plan)) } +/// Persist the retarget plan generated by the last successful sync run. pub fn save_last_sync_plan(plan: &LastSyncPlan) -> Result<(), String> { let path = last_sync_plan_path()?; let parent = path @@ -108,6 +136,7 @@ pub fn save_last_sync_plan(plan: &LastSyncPlan) -> Result<(), String> { fs::write(&path, raw).map_err(|_| format!("failed to write state at {}", path.display())) } +/// Remove the cached retarget plan from the last successful sync run. pub fn clear_last_sync_plan() -> Result<(), String> { let path = last_sync_plan_path()?; if !path.exists() { @@ -117,10 +146,12 @@ pub fn clear_last_sync_plan() -> Result<(), String> { fs::remove_file(&path).map_err(|_| format!("failed to remove sync state at {}", path.display())) } +/// Return the path to the shared sync/push state file under `.git/stck/`. pub fn state_file_path() -> Result { Ok(gitops::git_dir()?.join("stck").join("last-plan.json")) } +/// Return the path to the cached last-sync plan file under `.git/stck/`. pub fn last_sync_plan_path() -> Result { Ok(gitops::git_dir()?.join("stck").join("last-sync-plan.json")) } diff --git a/src/util.rs b/src/util.rs index 4ce4342..736f468 100644 --- a/src/util.rs +++ b/src/util.rs @@ -1,3 +1,6 @@ +//! Small formatting helpers shared across subprocess-backed modules. + +/// Append trimmed stderr output to a base error message when detail is available. pub fn with_stderr(base: &str, stderr: &[u8]) -> String { let detail = String::from_utf8_lossy(stderr).trim().to_string(); if detail.is_empty() {