Skip to main content

autoschematic_core/
git_util.rs

1use std::{
2    fs,
3    path::{Path, PathBuf},
4};
5
6use anyhow::{Context, bail};
7use git2::{
8    Cred, FetchOptions, IndexAddOption, PushOptions, RemoteCallbacks, Repository, Status, StatusOptions,
9    build::{CheckoutBuilder, RepoBuilder},
10};
11use secrecy::{ExposeSecret, SecretBox};
12
13pub async fn clone_repo(
14    owner: &str,
15    repo: &str,
16    path: &Path,
17    head_ref: &str,
18    token: &SecretBox<str>,
19) -> Result<Repository, anyhow::Error> {
20    let owner_path = path.join(owner);
21    fs::create_dir_all(&owner_path)?;
22
23    let repo_path = path.join(owner).join(repo);
24
25    let repo_url = format!("https://github.com/{owner}/{repo}.git");
26
27    let mut callbacks = RemoteCallbacks::new();
28    callbacks.credentials(move |_url, _username_from_url, _allowed_types| {
29        // Typically, GitHub expects:
30        //   - Username: "x-access-token"
31        //   - Password: "<YOUR_TOKEN>"
32        Cred::userpass_plaintext("x-access-token", token.expose_secret())
33    });
34
35    let mut fetch_opts = FetchOptions::new();
36    fetch_opts.remote_callbacks(callbacks);
37    fetch_opts.depth(1);
38    let _checkout_opts = CheckoutBuilder::new();
39
40    let repository = match Repository::open(&repo_path) {
41        Ok(repository) => repository,
42        Err(_) => RepoBuilder::new()
43            .fetch_options(fetch_opts)
44            .branch(head_ref)
45            .clone(&repo_url, &repo_path)
46            .context("RepoBuilder::new()")?,
47    };
48
49    // let submodules = repository.submodules()?;
50
51    // let mut callbacks = RemoteCallbacks::new();
52    // callbacks.credentials(move |_url, _username_from_url, _allowed_types| {
53    //     // Typically, GitHub expects:
54    //     //   - Username: "x-access-token"
55    //     //   - Password: "<YOUR_TOKEN>"
56    //     Cred::userpass_plaintext("x-access-token", token.expose_secret())
57    // });
58
59    // let mut fetch_opts = FetchOptions::new();
60    // fetch_opts.remote_callbacks(callbacks);
61    // fetch_opts.depth(1);
62    // let _checkout_opts = CheckoutBuilder::new();
63
64    // let mut submodule_update_options = SubmoduleUpdateOptions::new();
65    // let update_opts = submodule_update_options.fetch(fetch_opts);
66
67    // for mut submodule in submodules {
68    //     tracing::info!(
69    //         "Cloning submodule {:?} at {:?}",
70    //         submodule.name(),
71    //         submodule.path()
72    //     );
73    //     submodule.init(false)?;
74    //     submodule.update(true, Some(update_opts))?;
75    // }
76
77    repository.remote_set_url("origin", &repo_url)?;
78
79    // repository.reset(
80    //     repository.find_("HEAD")?.,
81    //     git2::ResetType::Hard,
82    //     None,
83    // ).context("git reset")?;
84
85    Ok(repository)
86}
87
88pub async fn checkout_new_branch(repo_path: &Path, branch_name: &str) -> Result<Repository, anyhow::Error> {
89    let Ok(repository) = Repository::open(repo_path) else {
90        bail!("No repository at {}", &repo_path.to_str().unwrap_or_default())
91    };
92
93    {
94        let branch = repository.branch(branch_name, &repository.head()?.peel_to_commit()?, false)?;
95
96        // repository.checkout_tree(&obj, None)?;
97        repository.checkout_tree(&branch.get().peel(git2::ObjectType::Tree)?, None)?;
98        repository.set_head(&format!("refs/heads/{branch_name}"))?;
99    }
100
101    Ok(repository)
102}
103
104pub async fn checkout_branch(repo_path: &Path, branch_name: &str) -> anyhow::Result<()> {
105    let Ok(repository) = Repository::open(repo_path) else {
106        bail!("No repository at {}", &repo_path.to_str().unwrap_or_default())
107    };
108
109    let (object, reference) = repository.revparse_ext(branch_name)?;
110
111    repository.checkout_tree(&object, None)?;
112
113    match reference {
114        // gref is an actual reference like branches or tags
115        Some(gref) => {
116            repository.set_head(gref.name().unwrap())?;
117        }
118        // this is a commit, not a reference
119        None => {
120            repository.set_head_detached(object.id())?;
121        }
122    }
123
124    Ok(())
125}
126
127pub fn git_add(repo_path: &Path, path: &Path) -> anyhow::Result<()> {
128    // tracing::error!("git_add({:?}, {:?})", repo_path, path);
129
130    // let repo_path = path.join(owner).join(repo);
131    //
132
133    let Ok(repository) = Repository::open(repo_path) else {
134        bail!("No repository at {}", &repo_path.to_str().unwrap_or_default())
135    };
136
137    let mut index = repository.index()?;
138    index.add_all([path], IndexAddOption::default(), None)?;
139    index.write()?;
140    Ok(())
141}
142
143pub fn git_commit(repo_path: &Path, username: &str, email: &str, message: &str) -> anyhow::Result<()> {
144    let Ok(repository) = Repository::open(repo_path) else {
145        bail!("No repository at {}", &repo_path.to_str().unwrap_or_default())
146    };
147
148    let mut index = repository.index()?;
149    let oid = index.write_tree()?;
150    let parent_commit = repository.head()?.peel_to_commit()?;
151    let tree = repository.find_tree(oid)?;
152    let sig = git2::Signature::now(username, email)?;
153    repository.commit(Some("HEAD"), &sig, &sig, message, &tree, &[&parent_commit])?;
154
155    Ok(())
156}
157
158pub fn git_commit_and_push(repo_path: &Path, head_ref: &str, token: &SecretBox<str>, message: &str) -> anyhow::Result<()> {
159    let Ok(repository) = Repository::open(repo_path) else {
160        bail!("No repository at {}", &repo_path.to_str().unwrap_or_default())
161    };
162
163    let mut index = repository.index()?;
164    let oid = index.write_tree()?;
165    let parent_commit = repository.head()?.peel_to_commit()?;
166    let tree = repository.find_tree(oid)?;
167    let sig = git2::Signature::now("autoschematic", "apply@autoschematic.sh")?;
168    repository.commit(Some("HEAD"), &sig, &sig, message, &tree, &[&parent_commit])?;
169
170    let mut remote = repository.find_remote("origin")?;
171
172    let refspec = format!("refs/heads/{head_ref}:refs/heads/{head_ref}");
173
174    let mut callbacks = RemoteCallbacks::new();
175    callbacks.credentials(move |_url, _username_from_url, _allowed_types| {
176        // Typically, GitHub expects:
177        //   - Username: "x-access-token"
178        //   - Password: "<YOUR_TOKEN>"
179        Cred::userpass_plaintext("x-access-token", token.expose_secret())
180    });
181
182    let mut push_options = PushOptions::new();
183    push_options.remote_callbacks(callbacks);
184    remote.push::<&str>(&[&refspec], Some(&mut push_options))?;
185    Ok(())
186}
187
188pub fn pull_with_rebase(repo_path: &Path, branch_name: &str, token: &SecretBox<str>) -> Result<(), anyhow::Error> {
189    let Ok(repository) = Repository::open(repo_path) else {
190        bail!("No repository at {}", repo_path.to_str().unwrap_or_default())
191    };
192
193    let mut callbacks = RemoteCallbacks::new();
194    callbacks.credentials(move |_url, _username_from_url, _allowed_types| {
195        // Typically, GitHub expects:
196        //   - Username: "x-access-token"
197        //   - Password: "<YOUR_TOKEN>"
198        Cred::userpass_plaintext("x-access-token", token.expose_secret())
199    });
200
201    let mut fetch_opts = FetchOptions::new();
202    fetch_opts.remote_callbacks(callbacks);
203    // fetch_opts.depth(1);
204
205    let mut remote = repository.find_remote("origin")?;
206
207    remote.fetch(&[branch_name], Some(&mut fetch_opts), None)?;
208
209    let fetch_head = repository.find_reference("FETCH_HEAD")?;
210
211    let fetch_ref = repository.reference_to_annotated_commit(&fetch_head)?;
212
213    let branch_ref_name = format!("refs/heads/{branch_name}");
214    let mut branch_ref = repository.find_reference(&branch_ref_name)?;
215
216    let msg = format!("Fast-Forward: Setting {} to id: {}", branch_ref_name, fetch_ref.id());
217
218    branch_ref.set_target(fetch_ref.id(), &msg)?;
219
220    repository.set_head(&branch_ref_name)?;
221
222    repository.checkout_head(Some(
223        git2::build::CheckoutBuilder::default()
224            // For some reason the force is required to make the working directory actually get updated
225            // I suspect we should be adding some logic to handle dirty working directory states
226            // but this is just an example so maybe not.
227            .force(),
228    ))?;
229
230    Ok(())
231}
232
233pub fn get_head_sha(repo_path: &Path) -> anyhow::Result<String> {
234    if let Ok(repository) = Repository::open(repo_path) {
235        let head = repository.head()?;
236        Ok(head.peel_to_commit()?.id().to_string())
237    } else {
238        bail!("No repository at {}", repo_path.to_str().unwrap_or_default())
239    }
240}
241
242pub fn get_staged_files() -> Result<Vec<PathBuf>, git2::Error> {
243    // Discover the repository by looking in `.` and upwards
244    let repo = Repository::discover(".")?;
245
246    // Configure status options to only look at changes staged in the index
247    let mut status_opts = StatusOptions::new();
248    status_opts.show(git2::StatusShow::Index);
249    status_opts.include_untracked(false).renames_head_to_index(true);
250
251    // Gather statuses
252    let statuses = repo.statuses(Some(&mut status_opts))?;
253
254    // Filter for any kind of index change
255    let mut staged = Vec::new();
256    for entry in statuses.iter() {
257        let s = entry.status();
258
259        let is_staged = s.intersects(
260            Status::INDEX_NEW
261                | Status::INDEX_MODIFIED
262                | Status::INDEX_DELETED
263                | Status::INDEX_RENAMED
264                | Status::INDEX_TYPECHANGE,
265        );
266
267        if is_staged && let Some(path) = entry.path() {
268            staged.push(PathBuf::from(path));
269        }
270
271        if s.intersects(Status::INDEX_RENAMED)
272            && let Some(new_path) = entry.head_to_index().and_then(|d| d.new_file().path())
273        {
274            staged.push(PathBuf::from(new_path));
275        }
276    }
277    Ok(staged)
278}