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