Skip to main content

ralph/git/commit/
upstream.rs

1//! Upstream and push helpers.
2//!
3//! Purpose:
4//! - Implement upstream discovery, rev-list comparisons, fetch/rebase, and direct push helpers.
5//!
6//! Responsibilities:
7//! - Query upstream configuration and ahead/behind status.
8//! - Execute push, fetch, rebase, abort, and conflict-listing commands.
9//! - Provide shared reference helpers for rebase-aware push logic.
10//!
11//! Scope:
12//! - Upstream/ref inspection and simple push operations only.
13//! - Retry loops for non-fast-forward recovery live in `rebase_push.rs`.
14//!
15//! Usage:
16//! - Re-exported through `crate::git::commit` and consumed by command workflows.
17//!
18//! Invariants/assumptions:
19//! - `rev-list --left-right --count` output must always parse into two integers.
20//! - Push failures are classified through `GitError` helpers.
21
22use anyhow::Context;
23use std::path::Path;
24
25use crate::git::error::{GitError, classify_push_error, git_output, git_run};
26
27/// Get the configured upstream for the current branch.
28///
29/// Returns the upstream reference (e.g. "origin/main") or an error if not configured.
30pub fn upstream_ref(repo_root: &Path) -> Result<String, GitError> {
31    let output = git_output(
32        repo_root,
33        &["rev-parse", "--abbrev-ref", "--symbolic-full-name", "@{u}"],
34    )
35    .with_context(|| {
36        format!(
37            "run git rev-parse --abbrev-ref --symbolic-full-name @{{u}} in {}",
38            repo_root.display()
39        )
40    })?;
41
42    if !output.status.success() {
43        let stderr = String::from_utf8_lossy(&output.stderr);
44        return Err(classify_push_error(&stderr));
45    }
46
47    let value = String::from_utf8_lossy(&output.stdout).trim().to_string();
48    if value.is_empty() {
49        return Err(GitError::NoUpstreamConfigured);
50    }
51    Ok(value)
52}
53
54/// Check if HEAD is ahead of the configured upstream.
55///
56/// Returns true if there are local commits that haven't been pushed.
57pub fn is_ahead_of_upstream(repo_root: &Path) -> Result<bool, GitError> {
58    let upstream = upstream_ref(repo_root)?;
59    let (_behind, ahead) = rev_list_left_right_counts(repo_root, &format!("{upstream}...HEAD"))?;
60    Ok(ahead > 0)
61}
62
63/// Push HEAD to the configured upstream.
64///
65/// Returns an error if push fails due to authentication, missing upstream,
66/// or other git errors.
67pub fn push_upstream(repo_root: &Path) -> Result<(), GitError> {
68    let output = git_output(repo_root, &["push"])
69        .with_context(|| format!("run git push in {}", repo_root.display()))?;
70
71    if output.status.success() {
72        return Ok(());
73    }
74
75    let stderr = String::from_utf8_lossy(&output.stderr);
76    Err(classify_push_error(&stderr))
77}
78
79/// Push HEAD to origin and create upstream tracking.
80///
81/// Intended for new branches that do not have an upstream configured yet.
82pub fn push_upstream_allow_create(repo_root: &Path) -> Result<(), GitError> {
83    let output = git_output(repo_root, &["push", "-u", "origin", "HEAD"])
84        .with_context(|| format!("run git push -u origin HEAD in {}", repo_root.display()))?;
85
86    if output.status.success() {
87        return Ok(());
88    }
89
90    let stderr = String::from_utf8_lossy(&output.stderr);
91    Err(classify_push_error(&stderr))
92}
93
94/// Fetch a specific branch from origin.
95pub fn fetch_branch(repo_root: &Path, remote: &str, branch: &str) -> Result<(), GitError> {
96    git_run(repo_root, &["fetch", remote, branch])
97        .with_context(|| format!("fetch {} {} in {}", remote, branch, repo_root.display()))?;
98    Ok(())
99}
100
101/// Check if the current branch is behind its upstream.
102///
103/// Returns true if the upstream has commits that are not in the current branch.
104pub fn is_behind_upstream(repo_root: &Path, branch: &str) -> Result<bool, GitError> {
105    fetch_branch(repo_root, "origin", branch)?;
106
107    let upstream = format!("origin/{}", branch);
108    let (_ahead, behind) = rev_list_left_right_counts(repo_root, &format!("HEAD...{upstream}"))?;
109    Ok(behind > 0)
110}
111
112/// Rebase current branch onto a target reference.
113pub fn rebase_onto(repo_root: &Path, target: &str) -> Result<(), GitError> {
114    git_run(repo_root, &["fetch", "origin", "--prune"])
115        .with_context(|| format!("fetch before rebase in {}", repo_root.display()))?;
116    git_run(repo_root, &["rebase", target])
117        .with_context(|| format!("rebase onto {} in {}", target, repo_root.display()))?;
118    Ok(())
119}
120
121/// Abort an in-progress rebase.
122pub fn abort_rebase(repo_root: &Path) -> Result<(), GitError> {
123    git_run(repo_root, &["rebase", "--abort"])
124        .with_context(|| format!("abort rebase in {}", repo_root.display()))?;
125    Ok(())
126}
127
128/// List files with merge conflicts.
129///
130/// Returns a list of file paths that have unresolved merge conflicts.
131pub fn list_conflict_files(repo_root: &Path) -> Result<Vec<String>, GitError> {
132    let output =
133        git_output(repo_root, &["diff", "--name-only", "--diff-filter=U"]).with_context(|| {
134            format!(
135                "run git diff --name-only --diff-filter=U in {}",
136                repo_root.display()
137            )
138        })?;
139
140    if !output.status.success() {
141        let stderr = String::from_utf8_lossy(&output.stderr).to_string();
142        return Err(GitError::CommandFailed {
143            args: "diff --name-only --diff-filter=U".to_string(),
144            code: output.status.code(),
145            stderr: stderr.trim().to_string(),
146        });
147    }
148
149    let stdout = String::from_utf8_lossy(&output.stdout);
150    Ok(stdout
151        .lines()
152        .map(|s| s.trim().to_string())
153        .filter(|s| !s.is_empty())
154        .collect())
155}
156
157/// Push the current branch to a remote.
158///
159/// This pushes HEAD to the current branch on the specified remote.
160pub fn push_current_branch(repo_root: &Path, remote: &str) -> Result<(), GitError> {
161    let output = git_output(repo_root, &["push", remote, "HEAD"])
162        .with_context(|| format!("run git push {} HEAD in {}", remote, repo_root.display()))?;
163
164    if output.status.success() {
165        return Ok(());
166    }
167
168    let stderr = String::from_utf8_lossy(&output.stderr);
169    Err(classify_push_error(&stderr))
170}
171
172/// Push HEAD to a specific branch on a remote.
173///
174/// This pushes HEAD to the specified branch on the remote, creating the branch if needed.
175/// Used in direct-push parallel mode to push directly to the base branch.
176pub fn push_head_to_branch(repo_root: &Path, remote: &str, branch: &str) -> Result<(), GitError> {
177    let refspec = format!("HEAD:{}", branch);
178    let output = git_output(repo_root, &["push", remote, &refspec]).with_context(|| {
179        format!(
180            "run git push {} HEAD:{} in {}",
181            remote,
182            branch,
183            repo_root.display()
184        )
185    })?;
186
187    if output.status.success() {
188        return Ok(());
189    }
190
191    let stderr = String::from_utf8_lossy(&output.stderr);
192    Err(classify_push_error(&stderr))
193}
194
195pub(super) fn reference_exists(repo_root: &Path, reference: &str) -> Result<bool, GitError> {
196    let output = git_output(repo_root, &["rev-parse", "--verify", "--quiet", reference])
197        .with_context(|| {
198            format!(
199                "run git rev-parse --verify --quiet {} in {}",
200                reference,
201                repo_root.display()
202            )
203        })?;
204    if output.status.success() {
205        return Ok(true);
206    }
207    if output.status.code() == Some(1) {
208        return Ok(false);
209    }
210    let stderr = String::from_utf8_lossy(&output.stderr).to_string();
211    Err(GitError::CommandFailed {
212        args: format!("rev-parse --verify --quiet {}", reference),
213        code: output.status.code(),
214        stderr: stderr.trim().to_string(),
215    })
216}
217
218pub(super) fn is_ahead_of_ref(repo_root: &Path, reference: &str) -> Result<bool, GitError> {
219    let (_behind, ahead) = rev_list_left_right_counts(repo_root, &format!("{reference}...HEAD"))?;
220    Ok(ahead > 0)
221}
222
223pub(super) fn set_upstream_to(repo_root: &Path, upstream: &str) -> Result<(), GitError> {
224    git_run(repo_root, &["branch", "--set-upstream-to", upstream])
225        .with_context(|| format!("set upstream to {} in {}", upstream, repo_root.display()))?;
226    Ok(())
227}
228
229pub(super) fn rev_list_left_right_counts(
230    repo_root: &Path,
231    range: &str,
232) -> Result<(u32, u32), GitError> {
233    let output = git_output(repo_root, &["rev-list", "--left-right", "--count", range])
234        .with_context(|| {
235            format!(
236                "run git rev-list --left-right --count {} in {}",
237                range,
238                repo_root.display()
239            )
240        })?;
241
242    if !output.status.success() {
243        let stderr = String::from_utf8_lossy(&output.stderr).to_string();
244        return Err(GitError::CommandFailed {
245            args: format!("rev-list --left-right --count {}", range),
246            code: output.status.code(),
247            stderr: stderr.trim().to_string(),
248        });
249    }
250
251    let counts = String::from_utf8_lossy(&output.stdout);
252    let parts: Vec<&str> = counts.split_whitespace().collect();
253    if parts.len() != 2 {
254        return Err(GitError::UnexpectedRevListOutput(counts.trim().to_string()));
255    }
256
257    let left: u32 = parts[0].parse().context("parse left count")?;
258    let right: u32 = parts[1].parse().context("parse right count")?;
259    Ok((left, right))
260}