Skip to main content

git_wok/cmd/
update.rs

1use anyhow::*;
2use std::io::Write;
3
4use crate::{config, repo};
5
6pub fn update<W: Write>(
7    wok_config: &mut config::Config,
8    umbrella: &repo::Repo,
9    stdout: &mut W,
10    no_commit: bool,
11    include_umbrella: bool,
12) -> Result<()> {
13    writeln!(stdout, "Updating repositories...")?;
14
15    let mut saw_subrepo_updates = false;
16    let mut saw_conflicts = false;
17    let mut updated_repos = Vec::new(); // Track updated repos
18
19    if include_umbrella {
20        let (_, conflicts) =
21            update_repo(umbrella, &umbrella.head, "umbrella", true, stdout)?;
22        saw_conflicts |= conflicts;
23    }
24
25    // Step 1: Update each repo with fetch and merge
26    for config_repo in &wok_config.repos {
27        if config_repo.is_skipped_for("update") {
28            continue;
29        }
30
31        if let Some(subrepo) = umbrella.get_subrepo_by_path(&config_repo.path) {
32            let label = config_repo.path.display().to_string();
33            let target_branch = if config_repo.head.trim().is_empty() {
34                umbrella.head.clone()
35            } else {
36                config_repo.head.clone()
37            };
38            let (updated, conflicts) =
39                update_repo(subrepo, &target_branch, &label, false, stdout)?;
40            saw_subrepo_updates |= updated;
41            saw_conflicts |= conflicts;
42
43            // Track updated repos
44            if updated {
45                let commit_hash = get_current_commit_hash(&subrepo.git_repo)?;
46                updated_repos.push((
47                    config_repo.path.to_string_lossy().to_string(),
48                    target_branch,
49                    commit_hash,
50                ));
51            }
52        }
53    }
54
55    // Step 2: Stage all submodule changes in umbrella repo
56    let staged_changes = stage_submodule_changes(&umbrella.git_repo)?;
57
58    if saw_conflicts {
59        writeln!(
60            stdout,
61            "Skipped committing umbrella repo due to merge conflicts"
62        )?;
63        return Ok(());
64    }
65
66    if no_commit {
67        if staged_changes || saw_subrepo_updates {
68            writeln!(
69                stdout,
70                "Changes staged; commit skipped because --no-commit was provided"
71            )?;
72        } else {
73            writeln!(stdout, "No submodule updates detected; nothing to commit")?;
74        }
75        return Ok(());
76    }
77
78    // Step 3: Commit the updated submodule state
79    if !staged_changes {
80        writeln!(stdout, "No submodule updates detected; nothing to commit")?;
81        return Ok(());
82    }
83
84    commit_submodule_updates(&umbrella.git_repo, &updated_repos)?;
85
86    writeln!(stdout, "Updated submodule state committed")?;
87    Ok(())
88}
89
90fn update_repo<W: Write>(
91    repo: &repo::Repo,
92    branch_name: &str,
93    label: &str,
94    allow_dirty: bool,
95    stdout: &mut W,
96) -> Result<(bool, bool)> {
97    // Switch to the desired branch first
98    let switch_result = if allow_dirty {
99        repo.switch(branch_name)
100    } else {
101        repo.ensure_on_branch(branch_name)
102    };
103    if let Err(err) = switch_result {
104        writeln!(stdout, "- '{}': skipped '{}': {}", label, branch_name, err)?;
105        return Ok((false, false));
106    }
107
108    // Attempt to merge with remote changes
109    let merge_result = repo.merge(branch_name)?;
110
111    // Get the current commit hash for reporting
112    let current_commit = get_current_commit_hash(&repo.git_repo)?;
113    let short_commit = &current_commit[..std::cmp::min(8, current_commit.len())];
114
115    let mut updated = false;
116    let mut conflicts = false;
117
118    match merge_result {
119        repo::MergeResult::UpToDate => {
120            writeln!(
121                stdout,
122                "- '{}': already up to date on '{}' ({})",
123                label, branch_name, short_commit
124            )?;
125        },
126        repo::MergeResult::FastForward => {
127            updated = true;
128            writeln!(
129                stdout,
130                "- '{}': fast-forwarded '{}' to {}",
131                label, branch_name, short_commit
132            )?;
133        },
134        repo::MergeResult::Merged => {
135            updated = true;
136            writeln!(
137                stdout,
138                "- '{}': merged '{}' to {}",
139                label, branch_name, short_commit
140            )?;
141        },
142        repo::MergeResult::Rebased => {
143            updated = true;
144            writeln!(
145                stdout,
146                "- '{}': rebased '{}' to {}",
147                label, branch_name, short_commit
148            )?;
149        },
150        repo::MergeResult::Conflicts => {
151            conflicts = true;
152            writeln!(
153                stdout,
154                "- '{}': merge conflicts in '{}' ({}), manual resolution required",
155                label, branch_name, short_commit
156            )?;
157        },
158    }
159
160    Ok((updated, conflicts))
161}
162
163fn get_current_commit_hash(git_repo: &git2::Repository) -> Result<String> {
164    let head = git_repo.head()?;
165    let commit = head.peel_to_commit()?;
166    Ok(commit.id().to_string())
167}
168
169fn stage_submodule_changes(git_repo: &git2::Repository) -> Result<bool> {
170    let head_tree = git_repo
171        .head()
172        .ok()
173        .and_then(|head| head.peel_to_tree().ok());
174    let mut index = git_repo.index()?;
175
176    for submodule in git_repo.submodules()? {
177        let submodule_path = submodule.path();
178
179        // Only stage submodules that have a head (are initialized)
180        if let Some(_submodule_oid) = submodule.head_id() {
181            index.add_path(submodule_path)?;
182        }
183    }
184
185    index.write()?;
186
187    if let Some(tree) = head_tree.as_ref() {
188        let diff = git_repo.diff_tree_to_index(Some(tree), Some(&index), None)?;
189        Ok(diff.deltas().len() > 0)
190    } else {
191        Ok(!index.is_empty())
192    }
193}
194
195fn commit_submodule_updates(
196    git_repo: &git2::Repository,
197    updated_repos: &[(String, String, String)], // (name, branch, commit_hash)
198) -> Result<()> {
199    let signature = git_repo.signature()?;
200    let tree_id = git_repo.index()?.write_tree()?;
201    let tree = git_repo.find_tree(tree_id)?;
202
203    let head_ref = git_repo.head()?;
204    let parent_commit = head_ref.peel_to_commit()?;
205    let parent_tree = parent_commit.tree()?;
206
207    // Build commit message with update details
208    let commit_message =
209        build_update_commit_message(git_repo, &parent_tree, &tree, updated_repos)?;
210
211    git_repo.commit(
212        Some("HEAD"),
213        &signature,
214        &signature,
215        &commit_message,
216        &tree,
217        &[&parent_commit],
218    )?;
219
220    Ok(())
221}
222
223/// Build a commit message for update operation showing which repos were updated.
224fn build_update_commit_message(
225    git_repo: &git2::Repository,
226    parent_tree: &git2::Tree,
227    index_tree: &git2::Tree,
228    updated_repos: &[(String, String, String)], // (name, branch, commit_hash)
229) -> Result<String> {
230    // Get diff between parent tree and staged index
231    let diff = git_repo.diff_tree_to_tree(Some(parent_tree), Some(index_tree), None)?;
232
233    let mut changed_submodules = Vec::new();
234
235    // Build a map of updated repos for quick lookup
236    let updated_map: std::collections::HashMap<_, _> = updated_repos
237        .iter()
238        .map(|(name, branch, hash)| (name.clone(), (branch.clone(), hash.clone())))
239        .collect();
240
241    // Iterate through changed files in the diff
242    for delta in diff.deltas() {
243        if let Some(file_path) = delta.new_file().path()
244            && let Some(file_path_str) = file_path.to_str()
245        {
246            match git_repo.find_submodule(file_path_str) {
247                std::result::Result::Ok(submodule) => {
248                    let submodule_name = submodule.path().to_string_lossy().to_string();
249
250                    // Check if this was one of the updated repos
251                    if let Some((branch, hash)) = updated_map.get(&submodule_name) {
252                        let short_hash = &hash[..std::cmp::min(8, hash.len())];
253                        changed_submodules.push((
254                            submodule_name,
255                            format!("{} to {}", branch, short_hash),
256                        ));
257                    }
258                },
259                std::result::Result::Err(_) => continue,
260            }
261        }
262    }
263
264    // Build the commit message
265    let mut message = String::from("Update submodules to latest");
266
267    if !changed_submodules.is_empty() {
268        message.push_str("\n\nUpdated submodules:");
269        for (name, info) in &changed_submodules {
270            message.push_str(&format!("\n- {}: {}", name, info));
271        }
272    }
273
274    std::result::Result::Ok(message)
275}