thoughts_tool/git/
sync.rs1use crate::git::shell_fetch;
2use crate::git::shell_push::push_current_branch;
3use crate::git::utils::is_worktree_dirty;
4use anyhow::Context;
5use anyhow::Result;
6use colored::*;
7use git2::IndexAddOption;
8use git2::Repository;
9use git2::Signature;
10use std::path::Path;
11
12pub struct GitSync {
13 repo: Repository,
14 repo_path: std::path::PathBuf,
15 subpath: Option<String>,
16}
17
18impl GitSync {
19 pub fn new(repo_path: &Path, subpath: Option<String>) -> Result<Self> {
20 let repo = Repository::open(repo_path)?;
21 Ok(Self {
22 repo,
23 repo_path: repo_path.to_path_buf(),
24 subpath,
25 })
26 }
27
28 pub async fn sync(&self, mount_name: &str) -> Result<()> {
29 println!(" {} {}", "Syncing".cyan(), mount_name);
30
31 let changes_staged = self.stage_changes().await?;
33
34 if changes_staged {
36 self.commit(mount_name).await?;
37 println!(" {} Committed changes", "✓".green());
38 } else {
39 println!(" {} No changes to commit", "○".dimmed());
40 }
41
42 match self.pull_rebase().await {
44 Ok(pulled) => {
45 if pulled {
46 println!(" {} Pulled remote changes", "✓".green());
47 }
48 }
49 Err(e) => {
50 println!(" {} Pull failed: {}", "⚠".yellow(), e);
51 }
53 }
54
55 match self.push().await {
57 Ok(_) => println!(" {} Pushed to remote", "✓".green()),
58 Err(e) => {
59 println!(" {} Push failed: {}", "⚠".yellow(), e);
60 println!(" {} Changes saved locally only", "Info".dimmed());
61 }
62 }
63
64 Ok(())
65 }
66
67 async fn stage_changes(&self) -> Result<bool> {
68 let mut index = self.repo.index()?;
69
70 let pathspecs: Vec<String> = if let Some(subpath) = &self.subpath {
72 vec![
75 format!("{}/*", subpath), format!("{}/**/*", subpath), ]
78 } else {
79 vec![".".to_string()]
81 };
82
83 let flags = IndexAddOption::DEFAULT;
85
86 let mut staged_files = 0;
88
89 let cb = &mut |_path: &std::path::Path, _matched_spec: &[u8]| -> i32 {
91 staged_files += 1;
92 0 };
94
95 index.add_all(
97 pathspecs.iter(),
98 flags,
99 Some(cb as &mut git2::IndexMatchedPath),
100 )?;
101
102 index.update_all(pathspecs.iter(), None)?;
104
105 index.write()?;
106
107 let diff = match self.repo.head() {
110 Ok(head) => {
111 let head_tree = self.repo.find_commit(head.target().unwrap())?.tree()?;
112 self.repo
113 .diff_tree_to_index(Some(&head_tree), Some(&index), None)?
114 }
115 Err(e) if e.code() == git2::ErrorCode::UnbornBranch => {
116 self.repo.diff_tree_to_index(None, Some(&index), None)?
118 }
119 Err(e) => return Err(e.into()),
120 };
121
122 Ok(diff.stats()?.files_changed() > 0)
123 }
124
125 async fn commit(&self, mount_name: &str) -> Result<()> {
126 let sig = Signature::now("thoughts-sync", "thoughts@sync.local")?;
127 let tree_id = self.repo.index()?.write_tree()?;
128 let tree = self.repo.find_tree(tree_id)?;
129
130 let message = if let Some(subpath) = &self.subpath {
132 format!("Auto-sync thoughts for {mount_name} (subpath: {subpath})")
133 } else {
134 format!("Auto-sync thoughts for {mount_name}")
135 };
136
137 match self.repo.head() {
139 Ok(head) => {
140 let parent = self.repo.find_commit(head.target().unwrap())?;
142 self.repo
143 .commit(Some("HEAD"), &sig, &sig, &message, &tree, &[&parent])?;
144 }
145 Err(e) if e.code() == git2::ErrorCode::UnbornBranch => {
146 self.repo.commit(
148 Some("HEAD"),
149 &sig,
150 &sig,
151 &message,
152 &tree,
153 &[], )?;
155 }
156 Err(e) => return Err(e.into()),
157 }
158
159 Ok(())
160 }
161
162 async fn pull_rebase(&self) -> Result<bool> {
163 if self.repo.find_remote("origin").is_err() {
165 println!(
166 " {} No remote 'origin' configured (local-only)",
167 "Info".dimmed()
168 );
169 return Ok(false);
170 }
171
172 shell_fetch::fetch(&self.repo_path, "origin").with_context(|| {
174 format!(
175 "Fetch from origin failed for repo '{}'",
176 self.repo_path.display()
177 )
178 })?;
179
180 let head = self.repo.head()?;
182 let branch_name = head.shorthand().unwrap_or("main");
183
184 let upstream_oid = match self
186 .repo
187 .refname_to_id(&format!("refs/remotes/origin/{branch_name}"))
188 {
189 Ok(oid) => oid,
190 Err(_) => {
191 return Ok(false);
193 }
194 };
195
196 let upstream_commit = self.repo.find_annotated_commit(upstream_oid)?;
197 let head_commit = self.repo.find_annotated_commit(head.target().unwrap())?;
198
199 let analysis = self.repo.merge_analysis(&[&upstream_commit])?;
201
202 if analysis.0.is_up_to_date() {
203 return Ok(false);
204 }
205
206 if analysis.0.is_fast_forward() {
207 if is_worktree_dirty(&self.repo)? {
209 anyhow::bail!(
210 "Cannot fast-forward: working tree has uncommitted changes. Please commit or stash before syncing."
211 );
212 }
213 let obj = self.repo.find_object(upstream_oid, None)?;
217 self.repo.reset(
218 &obj,
219 git2::ResetType::Hard,
220 Some(git2::build::CheckoutBuilder::default().force()),
221 )?;
222 return Ok(true);
223 }
224
225 let mut rebase =
227 self.repo
228 .rebase(Some(&head_commit), Some(&upstream_commit), None, None)?;
229
230 while let Some(operation) = rebase.next() {
231 if let Ok(_op) = operation {
232 if self.repo.index()?.has_conflicts() {
233 self.resolve_conflicts_prefer_remote()?;
235 }
236 rebase.commit(
237 None,
238 &Signature::now("thoughts-sync", "thoughts@sync.local")?,
239 None,
240 )?;
241 }
242 }
243
244 rebase.finish(None)?;
245 Ok(true)
246 }
247
248 async fn push(&self) -> Result<()> {
249 if self.repo.find_remote("origin").is_err() {
250 println!(
251 " {} No remote 'origin' configured (local-only)",
252 "Info".dimmed()
253 );
254 return Ok(());
255 }
256
257 let head = self.repo.head()?;
258 let branch = head.shorthand().unwrap_or("main");
259
260 push_current_branch(&self.repo_path, "origin", branch)?;
262 Ok(())
263 }
264
265 fn resolve_conflicts_prefer_remote(&self) -> Result<()> {
266 let mut index = self.repo.index()?;
267 let conflicts: Vec<_> = index.conflicts()?.collect::<Result<Vec<_>, _>>()?;
268
269 for conflict in conflicts {
270 if let Some(their) = conflict.their {
272 index.add(&their)?;
273 } else if let Some(our) = conflict.our {
274 index.add(&our)?;
276 }
277 }
278
279 index.write()?;
280 Ok(())
281 }
282}