Skip to main content

thoughts_tool/git/
sync.rs

1use 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        // 1. Stage changes (respecting subpath)
32        let changes_staged = self.stage_changes().await?;
33
34        // 2. Commit if there are changes
35        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        // 3. Pull with rebase
43        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                // Continue anyway - will try to push local changes
52            }
53        }
54
55        // 4. Push (non-fatal)
56        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        // Get the pathspec for staging
71        let pathspecs: Vec<String> = if let Some(subpath) = &self.subpath {
72            // Only stage files within subpath
73            // Use glob pattern to match all files recursively
74            vec![
75                format!("{}/*", subpath),    // Files directly in subpath
76                format!("{}/**/*", subpath), // Files in subdirectories
77            ]
78        } else {
79            // Stage all changes in repo
80            vec![".".to_string()]
81        };
82
83        // Configure flags for proper subpath handling
84        let flags = IndexAddOption::DEFAULT;
85
86        // Track if we staged anything
87        let mut staged_files = 0;
88
89        // Stage new and modified files with callback to track what we're staging
90        let cb = &mut |_path: &std::path::Path, _matched_spec: &[u8]| -> i32 {
91            staged_files += 1;
92            0 // Include this file
93        };
94
95        // Add all matching files
96        index.add_all(
97            pathspecs.iter(),
98            flags,
99            Some(cb as &mut git2::IndexMatchedPath),
100        )?;
101
102        // Update index to catch deletions in the pathspec
103        index.update_all(pathspecs.iter(), None)?;
104
105        index.write()?;
106
107        // Check if we actually have changes to commit
108        // Handle empty repo case where HEAD doesn't exist yet
109        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                // Empty repo - no HEAD yet, so everything in index is new
117                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        // Create descriptive commit message
131        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        // Handle both initial commit and subsequent commits
138        match self.repo.head() {
139            Ok(head) => {
140                // Normal commit with parent
141                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                // Initial commit - no parents
147                self.repo.commit(
148                    Some("HEAD"),
149                    &sig,
150                    &sig,
151                    &message,
152                    &tree,
153                    &[], // No parents for initial commit
154                )?;
155            }
156            Err(e) => return Err(e.into()),
157        }
158
159        Ok(())
160    }
161
162    async fn pull_rebase(&self) -> Result<bool> {
163        // Check if origin exists
164        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        // Fetch using shell git (uses system SSH, triggers 1Password)
173        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        // Get current branch
181        let head = self.repo.head()?;
182        let branch_name = head.shorthand().unwrap_or("main");
183
184        // Try to find the upstream commit
185        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                // No upstream branch yet
192                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        // Check if we need to rebase
200        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            // Safety gate: never force-checkout over local changes
208            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            // TODO(3): Migrate to gitoxide when worktree update support is added upstream
214            // (currently marked incomplete in gitoxide README)
215            // Fast-forward: update ref, index, and working tree atomically
216            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        // Need to rebase
226        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                    // Resolve conflicts by preferring remote
234                    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        // Use shell git push (triggers 1Password SSH prompts)
261        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            // Prefer their version (remote)
271            if let Some(their) = conflict.their {
272                index.add(&their)?;
273            } else if let Some(our) = conflict.our {
274                // If no remote version, keep ours
275                index.add(&our)?;
276            }
277        }
278
279        index.write()?;
280        Ok(())
281    }
282}