tedi 0.16.3

Personal productivity CLI for task tracking, time management, and GitHub issue integration
Documentation
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
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
//! Sync local issue changes with Github.
//!
//! ## Sync Algorithm
//!
//! The sync uses a four-merge approach:
//! ```text
//! local.merge(consensus, false);  local.merge(remote, force);
//! remote.merge(consensus, false); remote.merge(local, force);
//!
//! if local == remote { resolved } else { conflict }
//! ```
//!
//! ## MergeMode Semantics
//!
//! - `Normal`: Use timestamp-based resolution. Conflict if timestamps equal.
//! - `Force { prefer }`: On conflicts, take preferred side.
//! - `Reset { prefer }`: Take preferred side entirely.
//!
//! ## MergeMode Consumption
//!
//! The merge mode is consumed after first use (pre-editor sync), so post-editor
//! sync always uses `Normal`. This prevents accidental data loss.

use color_eyre::eyre::{Result, bail};
use tedi::{
	HollowIssue, Issue, IssueIndex, IssueLink, IssueSelector, LazyIssue, RepoInfo, VirtualIssue,
	local::{
		Consensus, FsReader, Local, LocalFs, LocalIssueSource, LocalPath,
		conflict::{ConflictOutcome, initiate_conflict_merge},
		consensus::load_consensus_issue,
	},
	remote::{Remote, RemoteSource},
	sink::Sink,
};
use tracing::instrument;
pub use types::*;
use v_utils::elog;

use super::merge::Merge;

/// Modify a local issue, then sync changes back to Github.
///
/// Caller is responsible for loading the issue (via `Issue::load(LocalIssueSource)`).
#[instrument(skip_all, fields(
	repo = ?issue.identity.repo_info(),
	issue_id = ?issue.git_id(),
	title = %issue.contents.title,
	offline,
	modifier = ?modifier,
))]
pub async fn modify_and_sync_issue(mut issue: Issue, offline: bool, modifier: Modifier, sync_opts: SyncOptions) -> Result<ModifyResult> {
	let repo_info = issue.identity.repo_info();
	let issue_index = IssueIndex::from(&issue);

	// if linked, check if local diverges from consensus. If yes, - need to sync the two. And while at it, let's pull remote too.
	if !offline && issue.is_linked() {
		let consensus = load_consensus_issue(issue_index).await?;
		let local_differs = consensus.as_ref().map(|c| *c != issue).unwrap_or(false); //IGNORED_ERROR: if consensus doesn't exist, then local doesn't need to think about it

		if sync_opts.pull || local_differs {
			elog!("triggered pre-open sync");
			core::sync(&mut issue, consensus, sync_opts.take_merge_mode()).await?;
		}
	}

	// expose for modification (by user or procedural)
	let new_modified = {
		let result = modifier.apply(&mut issue).await?;
		if !result.file_modified {
			v_utils::log!("Aborted (no changes made)");
			return Ok(result);
		}
		// Record this issue as the last modified
		let cache_path = v_utils::xdg_cache_file!("last_modified_issue");
		std::fs::write(&cache_path, issue.full_index().to_string()).ok();
		result
	};

	match offline || Local::is_virtual_project(repo_info) {
		true => {
			<Issue as Sink<LocalFs>>::sink(&mut issue, None).await?;
			println!("Offline: saved locally and exiting.");
			return Ok(new_modified);
		}
		false => {
			// Post-editor sync
			let mode = sync_opts.take_merge_mode();
			match issue.is_linked() {
				true => {
					let consensus = load_consensus_issue(issue_index).await?;
					core::sync(&mut issue, consensus, mode).await?;
				}
				false => {
					// New issue - check if parent needs syncing first
					let parent_index = issue.identity.parent_index;
					if let Some((i, _)) = parent_index.index().iter().enumerate().find(|(_, s)| matches!(s, IssueSelector::Title(_))) {
						// 1. Sink current issue to local so ancestor can find it
						<Issue as Sink<LocalFs>>::sink(&mut issue, None).await?;

						// 2. Load ancestor up to first Title selector
						let ancestor_index = IssueIndex::with_index(repo_info, parent_index.index()[..=i].to_vec());
						let ancestor_source = LocalIssueSource::<FsReader>::build(LocalPath::new(ancestor_index)).await?;
						let mut ancestor = Issue::load(ancestor_source).await?;
						let old_ancestor = ancestor.clone();

						// 3. Sink ancestor to Remote, then Local (with old state for cleanup)
						<Issue as Sink<Remote>>::sink(&mut ancestor, None).await?;
						<Issue as Sink<LocalFs>>::sink(&mut ancestor, Some(&old_ancestor)).await?;

						// 4. Commit
						<Issue as Sink<Consensus>>::sink(&mut ancestor, None).await?;
					} else {
						<Issue as Sink<Remote>>::sink(&mut issue, None).await?;
						<Issue as Sink<LocalFs>>::sink(&mut issue, None).await?;
						<Issue as Sink<Consensus>>::sink(&mut issue, None).await?;
					}
				}
			}

			Ok(new_modified)
		}
	}
}
#[derive(Debug, miette::Diagnostic, thiserror::Error)]
#[error(transparent)]
#[diagnostic(help("Your changes were saved to /tmp/tedi/rejected-changes.md — you can recover them from there."))]
struct RejectedEdit(#[from] tedi::ParseError);

mod core {
	use super::*;
	/// Sync an issue between local and remote using the four-merge algorithm.
	///
	/// ```text
	/// local.merge(consensus, false);  local.merge(remote, force);
	/// remote.merge(consensus, false); remote.merge(local, force);
	///
	/// if local == remote { resolved } else { conflict }
	/// ```
	///
	/// Returns `(resolved_issue, changed)` where `changed` indicates if any updates were made.
	#[instrument(skip_all, fields(?mode))]
	pub(super) async fn resolve_merge(local: Issue, consensus: Option<Issue>, remote: Issue, mode: MergeMode, repo_info: RepoInfo, issue_number: u64) -> Result<(Issue, bool)> {
		// Handle Reset mode - take one side entirely
		if let MergeMode::Reset { prefer } = mode {
			return match prefer {
				Side::Local => {
					let mut resolved = local;
					<Issue as Sink<Remote>>::sink(&mut resolved, Some(&remote)).await?;
					Ok((resolved, true))
				}
				Side::Remote => {
					let mut resolved = remote;
					<Issue as Sink<LocalFs>>::sink(&mut resolved, None).await?;
					Ok((resolved, true))
				}
			};
		}

		// In Force mode, we pass force=true only to the merge where `other` is the preferred side.
		// merge(other, true) means	 "take other's values on conflicts".
		// So: prefer Local → force on remote_merged.merge(local)
		//     prefer Remote → force on local_merged.merge(remote)
		let (force_local_wins, force_remote_wins) = match mode {
			MergeMode::Force { prefer: Side::Local } => (true, false),
			MergeMode::Force { prefer: Side::Remote } => (false, true),
			_ => (false, false),
		};

		// Apply four-merge algorithm
		let mut local_merged = local.clone();
		let mut remote_merged = remote.clone();

		if let Some(ref consensus) = consensus {
			local_merged.merge(consensus, false)?;
		}
		local_merged.merge(&remote, force_remote_wins)?;

		if let Some(consensus) = consensus {
			remote_merged.merge(&consensus, false)?;
		}
		remote_merged.merge(&local, force_local_wins)?;

		// Compare results
		match local_merged == remote_merged {
			true => {
				// Auto-resolved - sink to both sides
				let mut resolved = local_merged;
				<Issue as Sink<LocalFs>>::sink(&mut resolved, None).await?;
				<Issue as Sink<Remote>>::sink(&mut resolved, Some(&remote)).await?;
				Ok((resolved, true))
			}
			false => {
				// Conflict - initiate git merge
				match initiate_conflict_merge(repo_info, issue_number, &local_merged, &remote_merged)? {
					ConflictOutcome::AutoMerged => {
						unreachable!(
							"AutoMerged means when we triggered a merge of local against remote (which we've already checked are divergent), it succeeded. Which would be an implementation error, - whole point of the call is to record the conflict before getting user to resolve it manually."
						);
					}
					ConflictOutcome::NeedsResolution => {
						//TODO!: switch to a preview Error return type. This branch is EXPECTED to be reached during real-world usage, and must have first-class formatting and a miette error nicely propagated to user.
						bail!(
							"Conflict detected for {}/{}#{issue_number}.\n\
							Resolve using standard git tools, then re-run.",
							repo_info.owner(),
							repo_info.repo()
						);
					}
					ConflictOutcome::NoChanges => {
						// Git says no changes - take local
						Ok((local_merged, false))
					}
				}
			}
		}
	}

	#[instrument(skip_all, fields(?mode, has_consensus = consensus.is_some()))]
	pub(super) async fn sync(current_issue: &mut Issue, consensus: Option<Issue>, mode: MergeMode) -> Result<()> {
		println!("Syncing...");
		let issue_number = current_issue.git_id().expect(
			"can't be linked and not have number associated\nunless we die in a weird moment I guess. If this ever triggers, should fix it to set issue as pending (not linked) and sink",
		);
		let repo_info = current_issue.repo_info();

		let url = format!("https://github.com/{}/{}/issues/{issue_number}", repo_info.owner(), repo_info.repo());
		let link = IssueLink::parse(&url).expect("valid URL");
		let remote_source = RemoteSource::build(link, Some(&current_issue.identity.git_lineage()?))?; //DEPENDS: git_lineage() will error if any parent is not synced. //Q: should I move the logic for traversing IssueIndex in search of pending parents right here?
		let remote = Issue::load(remote_source).await?;

		let (resolved, changed) = core::resolve_merge(current_issue.clone(), consensus, remote, mode, repo_info, issue_number).await?;
		*current_issue = resolved;

		match changed {
			true => {
				// Re-sink local in case issue numbers changed
				<Issue as Sink<LocalFs>>::sink(current_issue, None).await?;
				<Issue as Sink<Consensus>>::sink(current_issue, None).await?;
			}
			false => println!("No changes."),
		}
		Ok(())
	}
}

mod types {
	use super::*;
	/// Which side to prefer in merge operations.
	#[derive(Clone, Copy, Debug, Eq, PartialEq)]
	pub enum Side {
		Local,
		Remote,
	}

	/// How to merge local and remote states.
	#[derive(Clone, Copy, Debug, Default, Eq, PartialEq)]
	pub enum MergeMode {
		/// Timestamp-based resolution. Conflict if can't auto-resolve.
		#[default]
		Normal,
		/// Force preferred side on conflicts, keep non-conflicting from both.
		Force { prefer: Side },
		/// Take preferred side entirely.
		Reset { prefer: Side },
	}

	/// Options for controlling sync behavior.
	///
	/// The `merge_mode` is consumed after first use (pre-editor sync),
	/// so post-editor sync runs with `MergeMode::Normal`.
	#[derive(Debug, Default)]
	pub struct SyncOptions {
		merge_mode: std::cell::Cell<Option<MergeMode>>,
		/// Fetch and sync from remote before opening editor.
		pub pull: bool,
	}

	impl SyncOptions {
		pub fn new(merge_mode: Option<MergeMode>, pull: bool) -> Self {
			Self {
				merge_mode: std::cell::Cell::new(merge_mode),
				pull,
			}
		}

		/// Take the merge mode, returning Normal if already taken or not set.
		pub fn take_merge_mode(&self) -> MergeMode {
			self.merge_mode.take().unwrap_or_default()
		}
	}

	/// Result of applying a modifier to an issue.
	pub struct ModifyResult {
		pub output: Option<String>,
		pub file_modified: bool,
	}

	/// A modifier that can be applied to an issue file.
	#[derive(Debug)]
	pub enum Modifier {
		Editor {
			open_at_blocker: bool,
		},
		BlockerPop {
			/// Number of parent ancestors to pop in addition to the current leaf.
			parents: usize,
		},
		BlockerAdd {
			text: String,
			nest: bool,
		},
		/// Replace the current (deepest) blocker's text in-place, preserving its position in the tree.
		BlockerSet {
			text: String,
		},
		/// Replace the issue's entire blocker sequence.
		/// Used by milestone editing to sync blocker changes back to individual issues.
		BlockerWrite {
			blockers: tedi::BlockerSequence,
		},
		/// Mock modifier that does nothing but reports file as modified. For testing.
		MockGhostEdit,
	}

	impl Modifier {
		#[tracing::instrument(skip_all)]
		pub(super) async fn apply(&self, issue: &mut Issue) -> Result<ModifyResult> {
			let old_issue = issue.clone();
			let vpath = Local::virtual_edit_path(issue);

			let result = match self {
				Modifier::Editor { open_at_blocker } => {
					let content = issue.serialize_virtual();
					std::fs::write(&vpath, &content)?;

					let mtime_before = std::fs::metadata(&vpath)?.modified()?;

					let position = if *open_at_blocker {
						issue.find_last_blocker_position().map(|(line, col)| crate::utils::Position::new(line, Some(col)))
					} else {
						None
					};

					crate::utils::open_file(&vpath, position).await?;

					let mtime_after = std::fs::metadata(&vpath)?.modified()?;
					let file_modified = mtime_after != mtime_before;

					let content = std::fs::read_to_string(&vpath)?;

					// `!u` on the last line means "undo": treat as if no changes were made
					let (content, file_modified) = {
						let trimmed = content.trim_end();
						let undo = trimmed.strip_suffix("!u").or_else(|| trimmed.strip_suffix("!U"));
						match undo {
							Some(before) if before.is_empty() || before.ends_with('\n') => (before.trim_end_matches('\n').to_string(), false),
							_ => (content, file_modified),
						}
					};

					tracing::Span::current().record("vpath", tracing::field::debug(&vpath));
					tracing::Span::current().record("content", content.as_str());
					let parent_idx = issue.identity.parent_index;
					let is_virtual = issue.identity.is_virtual;
					let hollow: HollowIssue = old_issue.clone().into();
					let virtual_issue = VirtualIssue::parse(&content, vpath.clone()).map_err(|e| {
						crate::utils::persist_rejected_changes(&content);
						RejectedEdit(e)
					})?;
					*issue = Issue::from_combined(hollow, virtual_issue, parent_idx, is_virtual)?;

					ModifyResult { output: None, file_modified }
				}
				Modifier::BlockerPop { parents } => {
					use crate::blocker_interactions::BlockerSequenceExt;
					let popped = issue
						.contents
						.blockers
						.pop(*parents)
						.ok_or_else(|| color_eyre::eyre::eyre!("Cannot pop {parents} parents — blocker chain is shorter"))?;
					ModifyResult {
						output: Some(format!("Popped: {popped}")),
						file_modified: true,
					}
				}
				Modifier::BlockerAdd { text, nest } => {
					use crate::blocker_interactions::BlockerSequenceExt;
					if *nest {
						issue.contents.blockers.add_child(text);
					} else {
						issue.contents.blockers.add(text);
					}
					ModifyResult { output: None, file_modified: true }
				}
				Modifier::BlockerSet { text } => {
					use crate::blocker_interactions::BlockerSequenceExt;
					let old = issue.contents.blockers.set(text);
					ModifyResult {
						output: old.map(|prev| format!("Replaced: {prev} -> {text}")),
						file_modified: true,
					}
				}
				Modifier::BlockerWrite { blockers } => {
					let file_modified = issue.contents.blockers != *blockers;
					issue.contents.blockers = blockers.clone();
					ModifyResult { output: None, file_modified }
				}
				Modifier::MockGhostEdit => ModifyResult { output: None, file_modified: true },
			};

			if result.file_modified {
				issue.post_update(&old_issue);
			}

			Ok(result)
		}
	}
}