Skip to main content

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    let on_branch = repo_on_branch(repo, branch_name)?;
172    if on_branch {
173        repo.refresh_worktree_force()?;
174        return Ok(SwitchResult::AlreadyOnBranch);
175    }
176
177    // Try to switch to the branch
178    match repo.switch_force(branch_name) {
179        Ok(_) => Ok(SwitchResult::Switched),
180        Err(_) => {
181            if create {
182                // Try to create the branch
183                create_and_switch_branch(repo, branch_name)?;
184                Ok(SwitchResult::Created)
185            } else {
186                Err(anyhow!(
187                    "Branch '{}' does not exist and --create not specified",
188                    branch_name
189                ))
190            }
191        },
192    }
193}
194
195fn create_and_switch_branch(repo: &repo::Repo, branch_name: &str) -> Result<()> {
196    // Get the current commit
197    let head = repo.git_repo.head()?;
198    let current_commit = head.peel_to_commit()?;
199
200    // Create the new branch
201    let _branch_ref = repo.git_repo.branch(branch_name, &current_commit, false)?;
202
203    // Switch to the new branch
204    repo.git_repo
205        .set_head(&format!("refs/heads/{}", branch_name))?;
206    repo.git_repo.checkout_head(None)?;
207
208    Ok(())
209}
210
211fn repo_on_branch(repo: &repo::Repo, branch_name: &str) -> Result<bool> {
212    if repo.git_repo.head_detached().with_context(|| {
213        format!(
214            "Cannot determine head state for repo at `{}`",
215            repo.work_dir.display()
216        )
217    })? {
218        return Ok(false);
219    }
220
221    let current = repo
222        .git_repo
223        .head()
224        .with_context(|| {
225            format!(
226                "Cannot find the head branch for repo at `{}`",
227                repo.work_dir.display()
228            )
229        })?
230        .shorthand()
231        .with_context(|| {
232            format!(
233                "Cannot resolve the head reference for repo at `{}`",
234                repo.work_dir.display()
235            )
236        })?
237        .to_owned();
238
239    Ok(current == branch_name)
240}
241
242fn lock_switched_repos(
243    umbrella: &repo::Repo,
244    config_path: &std::path::Path,
245    switched_repos: &[config::Repo],
246) -> Result<()> {
247    // Add all submodule changes to the index
248    let mut index = umbrella.git_repo.index()?;
249    for submodule in umbrella.git_repo.submodules()? {
250        let submodule_path = submodule.path();
251
252        // Only add submodules that have a head (are initialized)
253        if let Some(_submodule_oid) = submodule.head_id() {
254            // Add the submodule entry to the index
255            index.add_path(submodule_path)?;
256        }
257    }
258    index.write()?;
259
260    let wokfile_path =
261        config_path
262            .strip_prefix(&umbrella.work_dir)
263            .with_context(|| {
264                format!(
265                    "Wokfile must be inside umbrella repo to be committed: `{}`",
266                    config_path.display()
267                )
268            })?;
269    index.add_path(wokfile_path)?;
270    index.write()?;
271
272    // Check if there are any changes to commit
273    let signature = umbrella.git_repo.signature()?;
274    let tree_id = umbrella.git_repo.index()?.write_tree()?;
275    let tree = umbrella.git_repo.find_tree(tree_id)?;
276
277    let head_ref = umbrella.git_repo.head()?;
278    let parent_commit = head_ref.peel_to_commit()?;
279    let parent_tree = parent_commit.tree()?;
280
281    if tree.id() == parent_tree.id() {
282        return Ok(());
283    }
284
285    // Build commit message with switched submodule info
286    let (commit_message, _changed_submodules) =
287        build_switch_commit_message(umbrella, &parent_tree, &tree, switched_repos)?;
288
289    umbrella.git_repo.commit(
290        Some("HEAD"),
291        &signature,
292        &signature,
293        &commit_message,
294        &tree,
295        &[&parent_commit],
296    )?;
297
298    Ok(())
299}
300
301/// Build a commit message for switch operation showing which repos were switched.
302/// Returns (commit_message, changed_submodules_list)
303fn build_switch_commit_message(
304    umbrella: &repo::Repo,
305    parent_tree: &git2::Tree,
306    index_tree: &git2::Tree,
307    switched_repos: &[config::Repo],
308) -> Result<(String, Vec<(String, String)>)> {
309    // Get diff between parent tree and staged index
310    let diff = umbrella.git_repo.diff_tree_to_tree(
311        Some(parent_tree),
312        Some(index_tree),
313        None,
314    )?;
315
316    let mut changed_submodules = Vec::new();
317
318    // Build a map of switched repos for quick lookup
319    let switched_paths: std::collections::HashSet<_> = switched_repos
320        .iter()
321        .map(|r| r.path.to_string_lossy().to_string())
322        .collect();
323
324    // Iterate through changed files in the diff
325    for delta in diff.deltas() {
326        if let Some(file_path) = delta.new_file().path()
327            && let Some(file_path_str) = file_path.to_str()
328        {
329            match umbrella.git_repo.find_submodule(file_path_str) {
330                std::result::Result::Ok(submodule) => {
331                    let submodule_name = submodule.path().to_string_lossy().to_string();
332
333                    // Only include submodules that were actually switched
334                    if switched_paths.contains(&submodule_name) {
335                        let submodule_repo_path =
336                            umbrella.work_dir.join(submodule.path());
337                        match git2::Repository::open(&submodule_repo_path) {
338                            std::result::Result::Ok(subrepo_git) => {
339                                match subrepo_git.head() {
340                                    std::result::Result::Ok(head_ref) => {
341                                        if let Some(branch_name) = head_ref.shorthand()
342                                        {
343                                            changed_submodules.push((
344                                                submodule_name,
345                                                branch_name.to_string(),
346                                            ));
347                                        }
348                                    },
349                                    std::result::Result::Err(_) => continue,
350                                }
351                            },
352                            std::result::Result::Err(_) => continue,
353                        }
354                    }
355                },
356                std::result::Result::Err(_) => continue,
357            }
358        }
359    }
360
361    // Build the commit message
362    let mut message = String::from("Switch and lock submodule state");
363
364    if !changed_submodules.is_empty() {
365        message.push_str("\n\nSwitched submodules:");
366        for (name, branch) in &changed_submodules {
367            message.push_str(&format!("\n- {}: {}", name, branch));
368        }
369    }
370
371    std::result::Result::Ok((message, changed_submodules))
372}