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