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
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
use anyhow::{bail, Context, Result};
use chrono::Utc;
use std::process::Command;
use super::core::{
has_conflict_markers, resolve_accept_both, KnowledgeManager, SyncOutcome, KNOWLEDGE_BRANCH,
};
impl KnowledgeManager {
/// Initialize the knowledge cache directory.
///
/// If the `crosslink/knowledge` branch exists on the remote, fetches it and
/// creates a worktree. If not, creates an orphan branch with an initial
/// `index.md` page.
///
/// # Errors
/// Returns an error if git operations or filesystem writes fail.
pub fn init_cache(&self) -> Result<()> {
if self.cache_dir.exists() {
return Ok(());
}
// Check if remote branch exists
let has_remote = self
.git_in_repo(&["ls-remote", "--heads", &self.remote, KNOWLEDGE_BRANCH])
.is_ok_and(|o| !String::from_utf8_lossy(&o.stdout).trim().is_empty());
if has_remote {
// Fetch the remote branch
self.git_in_repo(&["fetch", &self.remote, KNOWLEDGE_BRANCH])?;
// Check if a local branch already exists
let has_local = self
.git_in_repo(&["rev-parse", "--verify", KNOWLEDGE_BRANCH])
.is_ok();
if has_local {
self.git_in_repo(&["worktree", "add", &self.cache_path_str(), KNOWLEDGE_BRANCH])?;
} else {
// Create local branch tracking remote
let remote_ref = format!("{}/{}", self.remote, KNOWLEDGE_BRANCH);
self.git_in_repo(&[
"worktree",
"add",
"-b",
KNOWLEDGE_BRANCH,
&self.cache_path_str(),
&remote_ref,
])?;
}
} else {
// No remote branch — create orphan branch with worktree
self.git_in_repo(&[
"worktree",
"add",
"--orphan",
"-b",
KNOWLEDGE_BRANCH,
&self.cache_path_str(),
])?;
// Initialize with index.md
let now = Utc::now().format("%Y-%m-%d").to_string();
let index_content = format!(
"\
---
title: Knowledge Index
tags: [index]
sources: []
contributors: []
created: {now}
updated: {now}
---
# Knowledge Index
This is the shared knowledge repository for the project.
"
);
std::fs::write(self.cache_dir.join("index.md"), index_content)?;
// Commit the initial state so the branch has at least one commit.
self.git_in_cache(&["add", "index.md"])?;
self.git_in_cache(&["commit", "-m", "Initialize crosslink/knowledge branch"])?;
}
Ok(())
}
/// Fetch the latest state from remote and rebase local changes on top.
///
/// If a rebase produces merge conflicts, falls back to an "accept both"
/// strategy: aborts the rebase, merges instead, and resolves any remaining
/// conflicts by keeping both versions. Returns the list of slugs that had
/// conflicts resolved.
///
/// # Errors
/// Returns an error if fetching, rebasing, or conflict resolution fails.
pub fn sync(&self) -> Result<SyncOutcome> {
let fetch_result = self.git_in_cache(&["fetch", &self.remote, KNOWLEDGE_BRANCH]);
if let Err(e) = &fetch_result {
let err_str = e.to_string();
if err_str.contains("Could not resolve host")
|| err_str.contains("Could not read from remote")
|| err_str.contains("does not appear to be a git repository")
|| err_str.contains("No such remote")
|| err_str.contains("couldn't find remote ref")
{
return Ok(SyncOutcome::default());
}
fetch_result?;
}
// Check for unpushed local commits. If any exist, rebase to preserve them.
let remote_ref = format!("{}/{}", self.remote, KNOWLEDGE_BRANCH);
let log_result = self.git_in_cache(&["log", &format!("{remote_ref}..HEAD"), "--oneline"]);
if let Ok(output) = &log_result {
let stdout = String::from_utf8_lossy(&output.stdout);
if !stdout.trim().is_empty() {
let rebase_result = self.git_in_cache(&["rebase", &remote_ref]);
if let Err(e) = &rebase_result {
let err_str = e.to_string();
if err_str.contains("unknown revision")
|| err_str.contains("ambiguous argument")
{
return Ok(SyncOutcome::default());
}
// Rebase failed — likely a conflict. Try accept-both fallback.
let outcome = self.handle_rebase_conflict(&remote_ref)?;
if !outcome.resolved_conflicts.is_empty() {
return Ok(outcome);
}
rebase_result?;
}
return Ok(SyncOutcome::default());
}
}
// No unpushed commits — check for uncommitted changes before resetting.
// A dirty worktree means write_page() was called without commit(),
// and reset --hard would destroy those edits.
if let Ok(status_output) = self.git_in_cache(&["status", "--porcelain"]) {
let status_str = String::from_utf8_lossy(&status_output.stdout);
if !status_str.trim().is_empty() {
tracing::warn!("knowledge sync: skipping reset — worktree has uncommitted changes");
return Ok(SyncOutcome::default());
}
}
let reset_result = self.git_in_cache(&["reset", "--hard", &remote_ref]);
if let Err(e) = &reset_result {
let err_str = e.to_string();
if err_str.contains("unknown revision") || err_str.contains("ambiguous argument") {
return Ok(SyncOutcome::default());
}
reset_result?;
}
Ok(SyncOutcome::default())
}
/// Push local commits to the remote.
///
/// If the push is rejected (non-fast-forward), attempts a pull --rebase.
/// If that rebase produces conflicts, falls back to "accept both" resolution.
///
/// # Errors
/// Returns an error if pushing or conflict resolution fails.
pub fn push(&self) -> Result<SyncOutcome> {
let push_result = self.git_in_cache(&["push", &self.remote, KNOWLEDGE_BRANCH]);
if let Err(e) = &push_result {
let err_str = e.to_string();
if err_str.contains("Could not resolve host")
|| err_str.contains("Could not read from remote")
{
return Ok(SyncOutcome::default());
}
if err_str.contains("rejected") || err_str.contains("non-fast-forward") {
let remote_ref = format!("{}/{}", self.remote, KNOWLEDGE_BRANCH);
// INTENTIONAL: fetch is best-effort — rebase below will use whatever state is available
let _ = self.git_in_cache(&["fetch", &self.remote, KNOWLEDGE_BRANCH]);
// Try rebase
let rebase_result = self.git_in_cache(&["rebase", &remote_ref]);
if rebase_result.is_err() {
// Rebase failed — try accept-both fallback
let outcome = self.handle_rebase_conflict(&remote_ref)?;
// Push after conflict resolution is best-effort — local state is
// consistent either way, but log failures so they aren't silent (#417).
if let Err(e) = self.git_in_cache(&["push", &self.remote, KNOWLEDGE_BRANCH]) {
tracing::warn!("knowledge push after conflict resolution failed: {e}");
}
return Ok(outcome);
}
// Push after rebase is best-effort — local state is consistent
// either way, but log failures so they aren't silent (#417).
if let Err(e) = self.git_in_cache(&["push", &self.remote, KNOWLEDGE_BRANCH]) {
tracing::warn!("knowledge push after rebase failed: {e}");
}
return Ok(SyncOutcome::default());
}
push_result?;
}
Ok(SyncOutcome::default())
}
/// Abort a failed rebase and fall back to merge with "accept both" resolution.
///
/// 1. Aborts the in-progress rebase
/// 2. Merges the remote ref
/// 3. If merge conflicts, resolves each .md file using accept-both
/// 4. Stages and commits the resolution
pub(super) fn handle_rebase_conflict(&self, remote_ref: &str) -> Result<SyncOutcome> {
// INTENTIONAL: rebase --abort is best-effort — may have already been aborted or not started
let _ = self.git_in_cache(&["rebase", "--abort"]);
// Attempt a merge instead
let merge_result = self.git_in_cache(&["merge", remote_ref, "--no-edit"]);
let resolved = if merge_result.is_err() {
// Merge has conflicts — resolve all .md files with accept-both
self.resolve_conflicts_in_cache()?
} else {
Vec::new()
};
if !resolved.is_empty() {
// Stage resolved files and commit
self.git_in_cache(&["add", "-A"])?;
let slugs_str = resolved.join(", ");
self.commit(&format!(
"knowledge: accept-both conflict resolution for {slugs_str}"
))?;
}
Ok(SyncOutcome {
resolved_conflicts: resolved,
})
}
/// Scan all `.md` files in the cache for conflict markers and resolve them.
///
/// Returns the list of slugs that had conflicts resolved.
pub(super) fn resolve_conflicts_in_cache(&self) -> Result<Vec<String>> {
let mut resolved = Vec::new();
if !self.cache_dir.exists() {
return Ok(resolved);
}
for entry in std::fs::read_dir(&self.cache_dir)? {
let entry = entry?;
let path = entry.path();
if path.extension().is_some_and(|e| e == "md") {
let content = std::fs::read_to_string(&path)?;
if has_conflict_markers(&content) {
let slug = path
.file_stem()
.unwrap_or_default()
.to_string_lossy()
.to_string();
let resolved_content = resolve_accept_both(&content);
std::fs::write(&path, &resolved_content)?;
resolved.push(slug);
}
}
}
Ok(resolved)
}
/// Stage all changes in the knowledge worktree and commit.
///
/// # Errors
/// Returns an error if staging or committing fails.
pub fn commit(&self, message: &str) -> Result<()> {
self.git_in_cache(&["add", "-A"])?;
let commit_result = self.git_in_cache(&["commit", "-m", message]);
if let Err(e) = &commit_result {
let err_str = e.to_string();
if err_str.contains("nothing to commit") || err_str.contains("no changes added") {
return Ok(());
}
commit_result?;
}
Ok(())
}
// --- Private git helpers ---
pub(super) fn git_in_repo(&self, args: &[&str]) -> Result<std::process::Output> {
let output = Command::new("git")
.current_dir(&self.repo_root)
.args(args)
.output()
.with_context(|| format!("Failed to run git {args:?}"))?;
if !output.status.success() {
let stderr = String::from_utf8_lossy(&output.stderr);
bail!("git {args:?} failed: {stderr}");
}
Ok(output)
}
pub(super) fn git_in_cache(&self, args: &[&str]) -> Result<std::process::Output> {
let output = Command::new("git")
.current_dir(&self.cache_dir)
.args(args)
.output()
.with_context(|| format!("Failed to run git {args:?} in knowledge cache"))?;
if !output.status.success() {
let stderr = String::from_utf8_lossy(&output.stderr);
bail!("git {args:?} in knowledge cache failed: {stderr}");
}
Ok(output)
}
}