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