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    // Push submodules first, then umbrella repo
68    // This ensures submodule commits exist remotely before the umbrella repo references them
69    for config_repo in &repos_to_push {
70        if let Some(subrepo) = umbrella.get_subrepo_by_path(&config_repo.path) {
71            match push_repo(subrepo, &target_branch, set_upstream) {
72                Ok(result) => match result {
73                    PushResult::Pushed => {
74                        writeln!(
75                            stdout,
76                            "- '{}': pushed to '{}'",
77                            config_repo.path.display(),
78                            target_branch
79                        )?;
80                    },
81                    PushResult::UpstreamSet => {
82                        writeln!(
83                            stdout,
84                            "- '{}': pushed to '{}' and set upstream",
85                            config_repo.path.display(),
86                            target_branch
87                        )?;
88                    },
89                    PushResult::UpToDate => {
90                        writeln!(
91                            stdout,
92                            "- '{}': already up to date",
93                            config_repo.path.display()
94                        )?;
95                    },
96                    PushResult::NoRemote => {
97                        writeln!(
98                            stdout,
99                            "- '{}': no remote configured, skipping",
100                            config_repo.path.display()
101                        )?;
102                    },
103                },
104                Err(e) => {
105                    writeln!(
106                        stdout,
107                        "- '{}': failed to push to '{}' - {}",
108                        config_repo.path.display(),
109                        target_branch,
110                        e
111                    )?;
112                },
113            }
114        }
115    }
116
117    // Push umbrella repo last, after all submodules
118    if include_umbrella {
119        match push_repo(umbrella, &target_branch, set_upstream) {
120            Ok(result) => match result {
121                PushResult::Pushed => {
122                    writeln!(stdout, "- 'umbrella': pushed to '{}'", target_branch)?;
123                },
124                PushResult::UpstreamSet => {
125                    writeln!(
126                        stdout,
127                        "- 'umbrella': pushed to '{}' and set upstream",
128                        target_branch
129                    )?;
130                },
131                PushResult::UpToDate => {
132                    writeln!(stdout, "- 'umbrella': already up to date")?;
133                },
134                PushResult::NoRemote => {
135                    writeln!(stdout, "- 'umbrella': no remote configured, skipping")?;
136                },
137            },
138            Err(e) => {
139                writeln!(
140                    stdout,
141                    "- 'umbrella': failed to push to '{}' - {}",
142                    target_branch, e
143                )?;
144            },
145        }
146    }
147
148    writeln!(
149        stdout,
150        "Successfully processed {} repositories",
151        total_targets
152    )?;
153    Ok(())
154}
155
156#[derive(Debug, Clone, PartialEq)]
157enum PushResult {
158    Pushed,
159    UpstreamSet,
160    UpToDate,
161    NoRemote,
162}
163
164/// Check if the local branch has new commits compared to the remote branch.
165/// Returns Ok(true) if push is needed, Ok(false) if already up-to-date, Err on failure.
166fn needs_push(
167    repo: &repo::Repo,
168    remote: &mut git2::Remote,
169    branch_name: &str,
170) -> Result<bool> {
171    // Get local branch OID
172    let local_branch_ref = format!("refs/heads/{}", branch_name);
173    let local_oid = repo.git_repo.refname_to_id(&local_branch_ref)?;
174
175    // Connect to remote to check remote branch state
176    let connection = remote.connect_auth(
177        git2::Direction::Push,
178        Some(repo.remote_callbacks()?),
179        None,
180    )?;
181
182    // Check if remote branch exists and get its OID
183    let remote_branch_ref = format!("refs/heads/{}", branch_name);
184    let mut remote_oid: Option<git2::Oid> = None;
185
186    for head in connection.list()?.iter() {
187        if head.name() == remote_branch_ref {
188            remote_oid = Some(head.oid());
189            break;
190        }
191    }
192
193    drop(connection);
194
195    // If remote branch doesn't exist, we need to push
196    let remote_oid = match remote_oid {
197        Some(oid) => oid,
198        None => return Ok(true), // New branch, push needed
199    };
200
201    // If OIDs match, no push needed
202    if local_oid == remote_oid {
203        return Ok(false);
204    }
205
206    // Check if local is ahead of remote (can fast-forward)
207    // If remote is ahead or diverged, we still return true because
208    // git push will fail with proper error message
209    Ok(true)
210}
211
212fn push_repo(
213    repo: &repo::Repo,
214    branch_name: &str,
215    set_upstream: bool,
216) -> Result<PushResult> {
217    // Get the remote name for this branch
218    let remote_name = repo.get_remote_name_for_branch(branch_name)?;
219
220    // Check if remote exists
221    let mut remote = match repo.git_repo.find_remote(&remote_name) {
222        Ok(remote) => remote,
223        Err(_) => {
224            return Ok(PushResult::NoRemote);
225        },
226    };
227
228    // Get the current branch reference
229    let branch_ref = format!("refs/heads/{}", branch_name);
230
231    // Check if the branch exists locally
232    if repo.git_repo.refname_to_id(&branch_ref).is_err() {
233        return Err(anyhow!("Branch '{}' does not exist locally", branch_name));
234    }
235
236    // Check if push is actually needed
237    match needs_push(repo, &mut remote, branch_name) {
238        Ok(false) => {
239            // Already up to date, skip the push entirely
240            return Ok(PushResult::UpToDate);
241        },
242        Ok(true) => {
243            // Push is needed, continue
244        },
245        Err(e) => {
246            // If we can't check remote state (e.g., network issue),
247            // proceed with push attempt and let git2 handle it
248            // This maintains backwards compatibility
249            eprintln!("Warning: Could not check remote state: {}", e);
250        },
251    }
252
253    // Prepare the refspec for pushing
254    let refspec = format!("{}:refs/heads/{}", branch_ref, branch_name);
255
256    // Perform the push
257    let mut push_options = git2::PushOptions::new();
258    push_options.remote_callbacks(repo.remote_callbacks()?);
259
260    match remote.push(&[&refspec], Some(&mut push_options)) {
261        Ok(_) => {
262            if set_upstream {
263                // Set the upstream branch
264                set_upstream_branch(repo, branch_name, &remote_name)?;
265                Ok(PushResult::UpstreamSet)
266            } else {
267                Ok(PushResult::Pushed)
268            }
269        },
270        Err(e) => {
271            // Check if it's an "up to date" error
272            if e.message().contains("up to date")
273                || e.message().contains("non-fast-forward")
274            {
275                Ok(PushResult::UpToDate)
276            } else {
277                Err(e.into())
278            }
279        },
280    }
281}
282
283fn set_upstream_branch(
284    repo: &repo::Repo,
285    branch_name: &str,
286    remote_name: &str,
287) -> Result<()> {
288    // Update the branch configuration to set upstream
289    let mut config = repo.git_repo.config()?;
290    config.set_str(&format!("branch.{}.remote", branch_name), remote_name)?;
291    config.set_str(
292        &format!("branch.{}.merge", branch_name),
293        &format!("refs/heads/{}", branch_name),
294    )?;
295
296    Ok(())
297}