git_wok/cmd/
push.rs

1use anyhow::*;
2use std::io::Write;
3use std::result::Result::Ok;
4
5use crate::{config, repo};
6
7#[allow(clippy::too_many_arguments)]
8pub fn push<W: Write>(
9    wok_config: &mut config::Config,
10    umbrella: &repo::Repo,
11    stdout: &mut W,
12    set_upstream: bool,
13    all: bool,
14    branch_name: Option<&str>,
15    include_umbrella: bool,
16    target_repos: &[std::path::PathBuf],
17) -> Result<()> {
18    // Determine the target branch
19    let target_branch = match branch_name {
20        Some(name) => name.to_string(),
21        None => umbrella.head.clone(),
22    };
23
24    // Determine which repos to push
25    let repos_to_push: Vec<config::Repo> = if all {
26        // Push all configured repos, skipping those opted out unless explicitly targeted
27        wok_config
28            .repos
29            .iter()
30            .filter(|config_repo| {
31                !config_repo.is_skipped_for("push")
32                    || target_repos.contains(&config_repo.path)
33            })
34            .cloned()
35            .collect()
36    } else if !target_repos.is_empty() {
37        // Push only specified repos
38        wok_config
39            .repos
40            .iter()
41            .filter(|config_repo| target_repos.contains(&config_repo.path))
42            .cloned()
43            .collect()
44    } else {
45        // Push repos that match the current main repo branch
46        wok_config
47            .repos
48            .iter()
49            .filter(|config_repo| config_repo.head == umbrella.head)
50            .cloned()
51            .collect()
52    };
53
54    let total_targets = repos_to_push.len() + usize::from(include_umbrella);
55
56    if total_targets == 0 {
57        writeln!(stdout, "No repositories to push")?;
58        return Ok(());
59    }
60
61    writeln!(
62        stdout,
63        "Pushing {} repositories to branch '{}'...",
64        total_targets, target_branch
65    )?;
66
67    if include_umbrella {
68        match push_repo(umbrella, &target_branch, set_upstream) {
69            Ok(result) => match result {
70                PushResult::Pushed => {
71                    writeln!(stdout, "- 'umbrella': pushed to '{}'", target_branch)?;
72                },
73                PushResult::UpstreamSet => {
74                    writeln!(
75                        stdout,
76                        "- 'umbrella': pushed to '{}' and set upstream",
77                        target_branch
78                    )?;
79                },
80                PushResult::UpToDate => {
81                    writeln!(stdout, "- 'umbrella': already up to date")?;
82                },
83                PushResult::NoRemote => {
84                    writeln!(stdout, "- 'umbrella': no remote configured, skipping")?;
85                },
86            },
87            Err(e) => {
88                writeln!(
89                    stdout,
90                    "- 'umbrella': failed to push to '{}' - {}",
91                    target_branch, e
92                )?;
93            },
94        }
95    }
96
97    // Push each repo
98    for config_repo in &repos_to_push {
99        if let Some(subrepo) = umbrella.get_subrepo_by_path(&config_repo.path) {
100            match push_repo(subrepo, &target_branch, set_upstream) {
101                Ok(result) => match result {
102                    PushResult::Pushed => {
103                        writeln!(
104                            stdout,
105                            "- '{}': pushed to '{}'",
106                            config_repo.path.display(),
107                            target_branch
108                        )?;
109                    },
110                    PushResult::UpstreamSet => {
111                        writeln!(
112                            stdout,
113                            "- '{}': pushed to '{}' and set upstream",
114                            config_repo.path.display(),
115                            target_branch
116                        )?;
117                    },
118                    PushResult::UpToDate => {
119                        writeln!(
120                            stdout,
121                            "- '{}': already up to date",
122                            config_repo.path.display()
123                        )?;
124                    },
125                    PushResult::NoRemote => {
126                        writeln!(
127                            stdout,
128                            "- '{}': no remote configured, skipping",
129                            config_repo.path.display()
130                        )?;
131                    },
132                },
133                Err(e) => {
134                    writeln!(
135                        stdout,
136                        "- '{}': failed to push to '{}' - {}",
137                        config_repo.path.display(),
138                        target_branch,
139                        e
140                    )?;
141                },
142            }
143        }
144    }
145
146    writeln!(
147        stdout,
148        "Successfully processed {} repositories",
149        total_targets
150    )?;
151    Ok(())
152}
153
154#[derive(Debug, Clone, PartialEq)]
155enum PushResult {
156    Pushed,
157    UpstreamSet,
158    UpToDate,
159    NoRemote,
160}
161
162/// Check if the local branch has new commits compared to the remote branch.
163/// Returns Ok(true) if push is needed, Ok(false) if already up-to-date, Err on failure.
164fn needs_push(
165    repo: &repo::Repo,
166    remote: &mut git2::Remote,
167    branch_name: &str,
168) -> Result<bool> {
169    // Get local branch OID
170    let local_branch_ref = format!("refs/heads/{}", branch_name);
171    let local_oid = repo.git_repo.refname_to_id(&local_branch_ref)?;
172
173    // Connect to remote to check remote branch state
174    let connection = remote.connect_auth(
175        git2::Direction::Push,
176        Some(repo.remote_callbacks()?),
177        None,
178    )?;
179
180    // Check if remote branch exists and get its OID
181    let remote_branch_ref = format!("refs/heads/{}", branch_name);
182    let mut remote_oid: Option<git2::Oid> = None;
183
184    for head in connection.list()?.iter() {
185        if head.name() == remote_branch_ref {
186            remote_oid = Some(head.oid());
187            break;
188        }
189    }
190
191    drop(connection);
192
193    // If remote branch doesn't exist, we need to push
194    let remote_oid = match remote_oid {
195        Some(oid) => oid,
196        None => return Ok(true), // New branch, push needed
197    };
198
199    // If OIDs match, no push needed
200    if local_oid == remote_oid {
201        return Ok(false);
202    }
203
204    // Check if local is ahead of remote (can fast-forward)
205    // If remote is ahead or diverged, we still return true because
206    // git push will fail with proper error message
207    Ok(true)
208}
209
210fn push_repo(
211    repo: &repo::Repo,
212    branch_name: &str,
213    set_upstream: bool,
214) -> Result<PushResult> {
215    // Get the remote name for this branch
216    let remote_name = repo.get_remote_name_for_branch(branch_name)?;
217
218    // Check if remote exists
219    let mut remote = match repo.git_repo.find_remote(&remote_name) {
220        Ok(remote) => remote,
221        Err(_) => {
222            return Ok(PushResult::NoRemote);
223        },
224    };
225
226    // Get the current branch reference
227    let branch_ref = format!("refs/heads/{}", branch_name);
228
229    // Check if the branch exists locally
230    if repo.git_repo.refname_to_id(&branch_ref).is_err() {
231        return Err(anyhow!("Branch '{}' does not exist locally", branch_name));
232    }
233
234    // Check if push is actually needed
235    match needs_push(repo, &mut remote, branch_name) {
236        Ok(false) => {
237            // Already up to date, skip the push entirely
238            return Ok(PushResult::UpToDate);
239        },
240        Ok(true) => {
241            // Push is needed, continue
242        },
243        Err(e) => {
244            // If we can't check remote state (e.g., network issue),
245            // proceed with push attempt and let git2 handle it
246            // This maintains backwards compatibility
247            eprintln!("Warning: Could not check remote state: {}", e);
248        },
249    }
250
251    // Prepare the refspec for pushing
252    let refspec = format!("{}:refs/heads/{}", branch_ref, branch_name);
253
254    // Perform the push
255    let mut push_options = git2::PushOptions::new();
256    push_options.remote_callbacks(repo.remote_callbacks()?);
257
258    match remote.push(&[&refspec], Some(&mut push_options)) {
259        Ok(_) => {
260            if set_upstream {
261                // Set the upstream branch
262                set_upstream_branch(repo, branch_name, &remote_name)?;
263                Ok(PushResult::UpstreamSet)
264            } else {
265                Ok(PushResult::Pushed)
266            }
267        },
268        Err(e) => {
269            // Check if it's an "up to date" error
270            if e.message().contains("up to date")
271                || e.message().contains("non-fast-forward")
272            {
273                Ok(PushResult::UpToDate)
274            } else {
275                Err(e.into())
276            }
277        },
278    }
279}
280
281fn set_upstream_branch(
282    repo: &repo::Repo,
283    branch_name: &str,
284    remote_name: &str,
285) -> Result<()> {
286    // Update the branch configuration to set upstream
287    let mut config = repo.git_repo.config()?;
288    config.set_str(&format!("branch.{}.remote", branch_name), remote_name)?;
289    config.set_str(
290        &format!("branch.{}.merge", branch_name),
291        &format!("refs/heads/{}", branch_name),
292    )?;
293
294    Ok(())
295}