1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
use crate::git::shell_fetch;
use crate::git::shell_push::push_current_branch;
use crate::git::utils::is_worktree_dirty;
use anyhow::{Context, Result};
use colored::*;
use git2::{IndexAddOption, Repository, Signature};
use std::path::Path;
pub struct GitSync {
repo: Repository,
repo_path: std::path::PathBuf,
subpath: Option<String>,
}
impl GitSync {
pub fn new(repo_path: &Path, subpath: Option<String>) -> Result<Self> {
let repo = Repository::open(repo_path)?;
Ok(Self {
repo,
repo_path: repo_path.to_path_buf(),
subpath,
})
}
pub async fn sync(&self, mount_name: &str) -> Result<()> {
println!(" {} {}", "Syncing".cyan(), mount_name);
// 1. Stage changes (respecting subpath)
let changes_staged = self.stage_changes().await?;
// 2. Commit if there are changes
if changes_staged {
self.commit(mount_name).await?;
println!(" {} Committed changes", "✓".green());
} else {
println!(" {} No changes to commit", "○".dimmed());
}
// 3. Pull with rebase
match self.pull_rebase().await {
Ok(pulled) => {
if pulled {
println!(" {} Pulled remote changes", "✓".green());
}
}
Err(e) => {
println!(" {} Pull failed: {}", "⚠".yellow(), e);
// Continue anyway - will try to push local changes
}
}
// 4. Push (non-fatal)
match self.push().await {
Ok(_) => println!(" {} Pushed to remote", "✓".green()),
Err(e) => {
println!(" {} Push failed: {}", "⚠".yellow(), e);
println!(" {} Changes saved locally only", "Info".dimmed());
}
}
Ok(())
}
async fn stage_changes(&self) -> Result<bool> {
let mut index = self.repo.index()?;
// Get the pathspec for staging
let pathspecs: Vec<String> = if let Some(subpath) = &self.subpath {
// Only stage files within subpath
// Use glob pattern to match all files recursively
vec![
format!("{}/*", subpath), // Files directly in subpath
format!("{}/**/*", subpath), // Files in subdirectories
]
} else {
// Stage all changes in repo
vec![".".to_string()]
};
// Configure flags for proper subpath handling
let flags = IndexAddOption::DEFAULT;
// Track if we staged anything
let mut staged_files = 0;
// Stage new and modified files with callback to track what we're staging
let cb = &mut |_path: &std::path::Path, _matched_spec: &[u8]| -> i32 {
staged_files += 1;
0 // Include this file
};
// Add all matching files
index.add_all(
pathspecs.iter(),
flags,
Some(cb as &mut git2::IndexMatchedPath),
)?;
// Update index to catch deletions in the pathspec
index.update_all(pathspecs.iter(), None)?;
index.write()?;
// Check if we actually have changes to commit
// Handle empty repo case where HEAD doesn't exist yet
let diff = match self.repo.head() {
Ok(head) => {
let head_tree = self.repo.find_commit(head.target().unwrap())?.tree()?;
self.repo
.diff_tree_to_index(Some(&head_tree), Some(&index), None)?
}
Err(e) if e.code() == git2::ErrorCode::UnbornBranch => {
// Empty repo - no HEAD yet, so everything in index is new
self.repo.diff_tree_to_index(None, Some(&index), None)?
}
Err(e) => return Err(e.into()),
};
Ok(diff.stats()?.files_changed() > 0)
}
async fn commit(&self, mount_name: &str) -> Result<()> {
let sig = Signature::now("thoughts-sync", "thoughts@sync.local")?;
let tree_id = self.repo.index()?.write_tree()?;
let tree = self.repo.find_tree(tree_id)?;
// Create descriptive commit message
let message = if let Some(subpath) = &self.subpath {
format!("Auto-sync thoughts for {mount_name} (subpath: {subpath})")
} else {
format!("Auto-sync thoughts for {mount_name}")
};
// Handle both initial commit and subsequent commits
match self.repo.head() {
Ok(head) => {
// Normal commit with parent
let parent = self.repo.find_commit(head.target().unwrap())?;
self.repo
.commit(Some("HEAD"), &sig, &sig, &message, &tree, &[&parent])?;
}
Err(e) if e.code() == git2::ErrorCode::UnbornBranch => {
// Initial commit - no parents
self.repo.commit(
Some("HEAD"),
&sig,
&sig,
&message,
&tree,
&[], // No parents for initial commit
)?;
}
Err(e) => return Err(e.into()),
}
Ok(())
}
async fn pull_rebase(&self) -> Result<bool> {
// Check if origin exists
if self.repo.find_remote("origin").is_err() {
println!(
" {} No remote 'origin' configured (local-only)",
"Info".dimmed()
);
return Ok(false);
}
// Fetch using shell git (uses system SSH, triggers 1Password)
shell_fetch::fetch(&self.repo_path, "origin").with_context(|| {
format!(
"Fetch from origin failed for repo '{}'",
self.repo_path.display()
)
})?;
// Get current branch
let head = self.repo.head()?;
let branch_name = head.shorthand().unwrap_or("main");
// Try to find the upstream commit
let upstream_oid = match self
.repo
.refname_to_id(&format!("refs/remotes/origin/{branch_name}"))
{
Ok(oid) => oid,
Err(_) => {
// No upstream branch yet
return Ok(false);
}
};
let upstream_commit = self.repo.find_annotated_commit(upstream_oid)?;
let head_commit = self.repo.find_annotated_commit(head.target().unwrap())?;
// Check if we need to rebase
let analysis = self.repo.merge_analysis(&[&upstream_commit])?;
if analysis.0.is_up_to_date() {
return Ok(false);
}
if analysis.0.is_fast_forward() {
// Safety gate: never force-checkout over local changes
if is_worktree_dirty(&self.repo)? {
anyhow::bail!(
"Cannot fast-forward: working tree has uncommitted changes. Please commit or stash before syncing."
);
}
// TODO(3): Migrate to gitoxide when worktree update support is added upstream
// (currently marked incomplete in gitoxide README)
// Fast-forward: update ref, index, and working tree atomically
let obj = self.repo.find_object(upstream_oid, None)?;
self.repo.reset(
&obj,
git2::ResetType::Hard,
Some(git2::build::CheckoutBuilder::default().force()),
)?;
return Ok(true);
}
// Need to rebase
let mut rebase =
self.repo
.rebase(Some(&head_commit), Some(&upstream_commit), None, None)?;
while let Some(operation) = rebase.next() {
if let Ok(_op) = operation {
if self.repo.index()?.has_conflicts() {
// Resolve conflicts by preferring remote
self.resolve_conflicts_prefer_remote()?;
}
rebase.commit(
None,
&Signature::now("thoughts-sync", "thoughts@sync.local")?,
None,
)?;
}
}
rebase.finish(None)?;
Ok(true)
}
async fn push(&self) -> Result<()> {
if self.repo.find_remote("origin").is_err() {
println!(
" {} No remote 'origin' configured (local-only)",
"Info".dimmed()
);
return Ok(());
}
let head = self.repo.head()?;
let branch = head.shorthand().unwrap_or("main");
// Use shell git push (triggers 1Password SSH prompts)
push_current_branch(&self.repo_path, "origin", branch)?;
Ok(())
}
fn resolve_conflicts_prefer_remote(&self) -> Result<()> {
let mut index = self.repo.index()?;
let conflicts: Vec<_> = index.conflicts()?.collect::<Result<Vec<_>, _>>()?;
for conflict in conflicts {
// Prefer their version (remote)
if let Some(their) = conflict.their {
index.add(&their)?;
} else if let Some(our) = conflict.our {
// If no remote version, keep ours
index.add(&our)?;
}
}
index.write()?;
Ok(())
}
}