git_wok/cmd/
switch.rs

1use anyhow::*;
2use std::io::Write;
3use std::result::Result::Ok;
4
5use crate::{config, repo};
6
7pub fn switch<W: Write>(
8    wok_config: &mut config::Config,
9    umbrella: &repo::Repo,
10    stdout: &mut W,
11    create: bool,
12    all: bool,
13    branch_name: Option<&str>,
14    target_repos: &[std::path::PathBuf],
15) -> Result<bool> {
16    let mut config_updated = false;
17    let mut submodule_changed = false;
18
19    // Determine the target branch
20    let target_branch = match branch_name {
21        Some(name) => name.to_string(),
22        None => umbrella.head.clone(),
23    };
24
25    // Determine which repos to switch
26    let repos_to_switch: Vec<config::Repo> = if all {
27        // Switch all configured repos, skipping those opted out unless explicitly targeted
28        wok_config
29            .repos
30            .iter()
31            .filter(|config_repo| {
32                !config_repo.is_skipped_for("switch")
33                    || target_repos.contains(&config_repo.path)
34            })
35            .cloned()
36            .collect()
37    } else if !target_repos.is_empty() {
38        // Switch only specified repos
39        wok_config
40            .repos
41            .iter()
42            .filter(|config_repo| target_repos.contains(&config_repo.path))
43            .cloned()
44            .collect()
45    } else {
46        // Switch repos that match the current main repo branch
47        wok_config
48            .repos
49            .iter()
50            .filter(|config_repo| config_repo.head == umbrella.head)
51            .cloned()
52            .collect()
53    };
54
55    if repos_to_switch.is_empty() {
56        writeln!(stdout, "No repositories to switch")?;
57        return Ok(config_updated);
58    }
59
60    writeln!(
61        stdout,
62        "Switching {} repositories to branch '{}'...",
63        repos_to_switch.len(),
64        target_branch
65    )?;
66
67    // Switch each repo
68    for config_repo in &repos_to_switch {
69        if let Some(subrepo) = umbrella.get_subrepo_by_path(&config_repo.path) {
70            match switch_repo(subrepo, &target_branch, create) {
71                Ok(result) => {
72                    config_updated |= wok_config
73                        .set_repo_head(config_repo.path.as_path(), &target_branch);
74
75                    match result {
76                        SwitchResult::Switched => {
77                            writeln!(
78                                stdout,
79                                "- '{}': switched to '{}'",
80                                config_repo.path.display(),
81                                target_branch
82                            )?;
83                            submodule_changed = true;
84                        },
85                        SwitchResult::Created => {
86                            writeln!(
87                                stdout,
88                                "- '{}': created and switched to '{}'",
89                                config_repo.path.display(),
90                                target_branch
91                            )?;
92                            submodule_changed = true;
93                        },
94                        SwitchResult::AlreadyOnBranch => {
95                            writeln!(
96                                stdout,
97                                "- '{}': already on '{}'",
98                                config_repo.path.display(),
99                                target_branch
100                            )?;
101                        },
102                    };
103                },
104                Err(e) => {
105                    writeln!(
106                        stdout,
107                        "- '{}': failed to switch to '{}' - {}",
108                        config_repo.path.display(),
109                        target_branch,
110                        e
111                    )?;
112                },
113            }
114        }
115    }
116
117    if submodule_changed {
118        // Perform lock operation on switched repos
119        writeln!(stdout, "Locking submodule state...")?;
120        lock_switched_repos(umbrella, &repos_to_switch, &target_branch)?;
121
122        writeln!(
123            stdout,
124            "Successfully switched and locked {} repositories",
125            repos_to_switch.len()
126        )?;
127    } else {
128        writeln!(stdout, "No submodule changes detected; skipping lock")?;
129        writeln!(
130            stdout,
131            "Successfully processed {} repositories",
132            repos_to_switch.len()
133        )?;
134    }
135
136    Ok(config_updated)
137}
138
139#[derive(Debug, Clone, PartialEq)]
140enum SwitchResult {
141    Switched,
142    Created,
143    AlreadyOnBranch,
144}
145
146fn switch_repo(
147    repo: &repo::Repo,
148    branch_name: &str,
149    create: bool,
150) -> Result<SwitchResult> {
151    // Check if we're already on the target branch
152    if repo_on_branch(repo, branch_name)? {
153        return Ok(SwitchResult::AlreadyOnBranch);
154    }
155
156    // Try to switch to the branch
157    match repo.switch(branch_name) {
158        Ok(_) => Ok(SwitchResult::Switched),
159        Err(_) => {
160            if create {
161                // Try to create the branch
162                create_and_switch_branch(repo, branch_name)?;
163                Ok(SwitchResult::Created)
164            } else {
165                Err(anyhow!(
166                    "Branch '{}' does not exist and --create not specified",
167                    branch_name
168                ))
169            }
170        },
171    }
172}
173
174fn create_and_switch_branch(repo: &repo::Repo, branch_name: &str) -> Result<()> {
175    // Get the current commit
176    let head = repo.git_repo.head()?;
177    let current_commit = head.peel_to_commit()?;
178
179    // Create the new branch
180    let _branch_ref = repo.git_repo.branch(branch_name, &current_commit, false)?;
181
182    // Switch to the new branch
183    repo.git_repo
184        .set_head(&format!("refs/heads/{}", branch_name))?;
185    repo.git_repo.checkout_head(None)?;
186
187    Ok(())
188}
189
190fn repo_on_branch(repo: &repo::Repo, branch_name: &str) -> Result<bool> {
191    if repo.git_repo.head_detached().with_context(|| {
192        format!(
193            "Cannot determine head state for repo at `{}`",
194            repo.work_dir.display()
195        )
196    })? {
197        return Ok(false);
198    }
199
200    let current = repo
201        .git_repo
202        .head()
203        .with_context(|| {
204            format!(
205                "Cannot find the head branch for repo at `{}`",
206                repo.work_dir.display()
207            )
208        })?
209        .shorthand()
210        .with_context(|| {
211            format!(
212                "Cannot resolve the head reference for repo at `{}`",
213                repo.work_dir.display()
214            )
215        })?
216        .to_owned();
217
218    Ok(current == branch_name)
219}
220
221fn lock_switched_repos(
222    umbrella: &repo::Repo,
223    switched_repos: &[config::Repo],
224    target_branch: &str,
225) -> Result<()> {
226    // Add all submodule changes to the index
227    let mut index = umbrella.git_repo.index()?;
228    for submodule in umbrella.git_repo.submodules()? {
229        let submodule_path = submodule.path();
230
231        // Only add submodules that have a head (are initialized)
232        if let Some(_submodule_oid) = submodule.head_id() {
233            // Add the submodule entry to the index
234            index.add_path(submodule_path)?;
235        }
236    }
237    index.write()?;
238
239    // Check if there are any changes to commit
240    let signature = umbrella.git_repo.signature()?;
241    let tree_id = umbrella.git_repo.index()?.write_tree()?;
242    let tree = umbrella.git_repo.find_tree(tree_id)?;
243
244    let head_ref = umbrella.git_repo.head()?;
245    let parent_commit = head_ref.peel_to_commit()?;
246    let parent_tree = parent_commit.tree()?;
247
248    if tree.id() == parent_tree.id() {
249        return Ok(());
250    }
251
252    // Build commit message with switched submodule info
253    let (commit_message, _changed_submodules) = build_switch_commit_message(
254        umbrella,
255        &parent_tree,
256        &tree,
257        switched_repos,
258        target_branch,
259    )?;
260
261    umbrella.git_repo.commit(
262        Some("HEAD"),
263        &signature,
264        &signature,
265        &commit_message,
266        &tree,
267        &[&parent_commit],
268    )?;
269
270    Ok(())
271}
272
273/// Build a commit message for switch operation showing which repos were switched.
274/// Returns (commit_message, changed_submodules_list)
275fn build_switch_commit_message(
276    umbrella: &repo::Repo,
277    parent_tree: &git2::Tree,
278    index_tree: &git2::Tree,
279    switched_repos: &[config::Repo],
280    target_branch: &str,
281) -> Result<(String, Vec<(String, String)>)> {
282    // Get diff between parent tree and staged index
283    let diff = umbrella.git_repo.diff_tree_to_tree(
284        Some(parent_tree),
285        Some(index_tree),
286        None,
287    )?;
288
289    let mut changed_submodules = Vec::new();
290
291    // Build a map of switched repos for quick lookup
292    let switched_paths: std::collections::HashSet<_> = switched_repos
293        .iter()
294        .map(|r| r.path.to_string_lossy().to_string())
295        .collect();
296
297    // Iterate through changed files in the diff
298    for delta in diff.deltas() {
299        if let Some(file_path) = delta.new_file().path()
300            && let Some(file_path_str) = file_path.to_str()
301        {
302            match umbrella.git_repo.find_submodule(file_path_str) {
303                std::result::Result::Ok(submodule) => {
304                    let submodule_name = submodule.path().to_string_lossy().to_string();
305
306                    // Only include submodules that were actually switched
307                    if switched_paths.contains(&submodule_name) {
308                        let submodule_repo_path =
309                            umbrella.work_dir.join(submodule.path());
310                        match git2::Repository::open(&submodule_repo_path) {
311                            std::result::Result::Ok(subrepo_git) => {
312                                match subrepo_git.head() {
313                                    std::result::Result::Ok(head_ref) => {
314                                        if let Some(branch_name) = head_ref.shorthand()
315                                        {
316                                            changed_submodules.push((
317                                                submodule_name,
318                                                branch_name.to_string(),
319                                            ));
320                                        }
321                                    },
322                                    std::result::Result::Err(_) => continue,
323                                }
324                            },
325                            std::result::Result::Err(_) => continue,
326                        }
327                    }
328                },
329                std::result::Result::Err(_) => continue,
330            }
331        }
332    }
333
334    // Build the commit message
335    let mut message = String::from("Switch and lock submodule state");
336
337    if !changed_submodules.is_empty() {
338        message.push_str(&format!("\n\nSwitched to '{}':", target_branch));
339        for (name, branch) in &changed_submodules {
340            message.push_str(&format!("\n- {}: {}", name, branch));
341        }
342    }
343
344    std::result::Result::Ok((message, changed_submodules))
345}