diffler-core 0.21.0

Core review engine for diffler: diffs, sessions, comments, viewed marks
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
419
420
421
422
423
424
425
426
//! Backend-agnostic VCS interface. Only the `git` and `repo` modules and test
//! fixtures may import git2.

use std::collections::HashMap;
use std::path::{Path, PathBuf};

use thiserror::Error;

use crate::diffalgo::DiffAlgorithm;
use crate::model::{DiffModel, HunkId};

#[derive(Debug, Error)]
pub enum VcsError {
    #[error(transparent)]
    Git(#[from] git2::Error),
    #[error("repository has no working directory")]
    NoWorkdir,
    /// Domain refusal, e.g. discarding a file with staged changes.
    #[error("{0}")]
    Rejected(String),
    #[error(transparent)]
    Io(#[from] std::io::Error),
}

#[derive(Debug, Clone, PartialEq, Eq)]
pub struct HeadInfo {
    /// Branch shorthand; `None` when HEAD is detached.
    pub branch: Option<String>,
    /// Abbreviated commit id; empty on an unborn branch.
    pub oid7: String,
    /// First line of the HEAD commit message; empty on an unborn branch.
    pub subject: String,
    /// Upstream branch shorthand, if configured.
    pub upstream: Option<String>,
    /// Commits on HEAD the upstream lacks. Zero without an upstream.
    pub ahead: usize,
    /// Commits on the upstream that HEAD lacks.
    pub behind: usize,
}

#[derive(Debug, Clone, PartialEq, Eq)]
pub struct LogEntry {
    pub oid: String,
    pub oid7: String,
    /// Shorthand names of references pointing at this commit.
    pub refs: Vec<String>,
    /// Full parent oids, first parent first.
    pub parents: Vec<String>,
    pub subject: String,
    pub author: String,
    pub time_unix: i64,
    /// Short jj change id; `None` outside a jj repo.
    pub change_id: Option<String>,
}

impl LogEntry {
    /// The id a reader names the commit by: the change id in a jj repo.
    pub fn short_id(&self) -> &str {
        self.change_id.as_deref().unwrap_or(&self.oid7)
    }
}

/// A commit that changed a file, and the path the file had there.
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct FileVersion {
    pub entry: LogEntry,
    pub path: String,
}

/// The jj working-copy change `@`, shown on a jj repo's head line.
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct ChangeInfo {
    pub change_id: String,
    /// `@`'s commit id when the view was read; a snapshot rewrites it.
    pub commit_id: String,
    /// A bookmark on `@`, else on its nearest bookmarked ancestor.
    pub bookmark: Option<String>,
    /// First line of the description; empty when none is set.
    pub description: String,
}

/// A jj verb on one change. A `rev` is `@`, or a full commit id that names
/// the change it belongs to, so a change rewritten since keeps its target.
#[derive(Debug, Clone, PartialEq, Eq)]
pub enum ChangeOp {
    New(String),
    Edit(String),
    /// An empty message clears the description.
    Describe {
        rev: String,
        message: String,
    },
    /// Into its parent. Both descriptions survive: the parent's, then the
    /// change's, when both are set.
    Squash(String),
    Abandon(String),
    Undo,
    Redo,
}

/// The local bookmark nearest `@` and the remote it tracks, which a jj pull
/// fetches and rebases onto.
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct PullTarget {
    pub bookmark: String,
    /// `None` when the bookmark tracks no remote bookmark.
    pub remote: Option<String>,
}

/// What a jj pull did after its fetch.
#[derive(Debug, Clone, PartialEq, Eq)]
pub enum PullOutcome {
    /// The remote no longer has `onto` (`name@remote`).
    Gone { onto: String },
    /// `@` already descends from `onto`.
    UpToDate { onto: String },
    /// `@`'s branch now sits on `onto`; `conflicts` of its changes conflict,
    /// and the local bookmark conflicts with the remote's when it moved on
    /// both sides.
    Rebased {
        onto: String,
        conflicts: usize,
        bookmark_conflicted: bool,
    },
}

/// What a jj-only verb answers in a git repo.
pub const JJ_ONLY: &str = "edit changes in a jj repo; this one is plain git";

/// A jj bookmark: `name` for a local one, `name@remote` for a remote one.
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct Bookmark {
    pub name: String,
    pub oid: String,
    pub remote: bool,
    pub tip_unix: i64,
}

/// What jj knows beyond the git store, read through the `jj` CLI.
#[derive(Debug, Clone, Default, PartialEq, Eq)]
pub struct JjView {
    /// `@`, `None` when jj's answer named no working copy.
    pub change: Option<ChangeInfo>,
    /// The first parent of `@`, which colocated git's HEAD sits on; `None`
    /// when that is jj's root commit.
    pub base: Option<String>,
    /// Every bookmark; filled only where the git store lacks them, which is
    /// a repo with no `.git`.
    pub bookmarks: Vec<Bookmark>,
    /// Short change id by full commit id, over recent history.
    pub change_ids: HashMap<String, String>,
}

#[derive(Debug, Clone, PartialEq, Eq)]
pub struct BranchInfo {
    pub name: String,
    pub is_head: bool,
    pub tip_unix: i64,
    /// `(ahead, behind)` against the upstream. Resolving one costs a graph
    /// walk, so listings leave it `None` and callers ask
    /// [`Vcs::divergence`] for the branches they show.
    pub divergence: Option<(usize, usize)>,
}

/// A network operation we run through the backend's CLI, so the user's own
/// auth applies and diffler holds no credentials.
#[derive(Debug, Clone, PartialEq, Eq)]
pub enum NetworkOp {
    Fetch,
    FetchAll,
    Push,
    PushSetUpstream { remote: String },
    Pull,
    PullFrom { remote: String, branch: String },
    PullRebase,
    PullMerge,
}

/// One working copy of the repository: git's main worktree, a linked one, or
/// a jj workspace.
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct Worktree {
    /// git's worktree name, or the jj workspace name.
    pub name: String,
    pub path: PathBuf,
    /// The checked-out branch, or what stands in for one.
    pub label: String,
    /// `None` for a detached HEAD, a missing folder and a jj workspace.
    pub branch: Option<String>,
    pub subject: String,
    pub time_unix: i64,
    /// The worktree this handle reads.
    pub current: bool,
    /// git's main worktree, which only goes away with the repository.
    pub main: bool,
    /// Its folder is gone, so removing it only drops the record.
    pub missing: bool,
}

/// A worktree change we run through the backend's CLI, so its hooks and
/// checkout filters run as they would in a shell.
#[derive(Debug, Clone, PartialEq, Eq)]
pub enum WorktreeOp {
    /// `new_branch` creates `branch` at HEAD first.
    Add {
        path: PathBuf,
        branch: String,
        new_branch: bool,
    },
    Remove {
        worktree: Worktree,
        force: bool,
    },
}

/// A run of consecutive lines a single commit last touched.
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct BlameSpan {
    /// 1-based first line of the run in the blamed content.
    pub start_line: u32,
    pub line_count: u32,
    pub oid: String,
    pub oid7: String,
    pub author: String,
    pub time_unix: i64,
    pub summary: String,
    /// False for lines that exist only in the worktree, which no commit owns.
    pub committed: bool,
}

/// Per-area views of the working tree, neogit-style sections.
#[derive(Debug, Clone, Default)]
pub struct StatusModel {
    pub untracked: DiffModel,
    pub unstaged: DiffModel,
    pub staged: DiffModel,
}

pub trait Vcs: Send {
    /// Resolved metadata directory. In a linked worktree this is the gitdir
    /// the `.git` file points at.
    fn git_dir(&self) -> Result<PathBuf, VcsError>;
    fn head(&self) -> Result<HeadInfo, VcsError>;
    /// jj's view, covering `log_limit` commits of history; `None` outside a
    /// jj repo. It costs two `jj` runs, so callers ask from a worker, and
    /// this handle adopts it.
    fn read_jj_view(&self, _log_limit: usize) -> Result<Option<JjView>, VcsError> {
        Ok(None)
    }
    /// Take a view another handle read, with no subprocess.
    fn adopt_jj_view(&self, _view: JjView) {}
    /// Whether this backend has a staging area. jj has none, so its whole
    /// working copy reads as [`StatusModel::staged`].
    fn has_index(&self) -> bool;
    fn status(&self) -> Result<StatusModel, VcsError>;
    /// HEAD vs index + worktree, untracked files included.
    fn working_tree_diff(&self) -> Result<DiffModel, VcsError>;
    /// [`Vcs::working_tree_diff`] taken from `base_oid`.
    fn tree_to_workdir_diff(&self, base_oid: &str) -> Result<DiffModel, VcsError>;
    /// Changes a single commit introduced over its first parent.
    fn commit_diff(&self, oid: &str) -> Result<DiffModel, VcsError>;
    /// `git diff <oldest>^..<newest>`. A root `oldest` diffs from the empty
    /// tree.
    fn range_diff(&self, oldest_oid: &str, newest_oid: &str) -> Result<DiffModel, VcsError>;
    /// `git diff <base> <newest>`; with `base` a merge base this is a
    /// three-dot diff.
    fn tree_diff(&self, base_oid: &str, newest_oid: &str) -> Result<DiffModel, VcsError>;
    fn merge_base(&self, a: &str, b: &str) -> Result<String, VcsError>;
    /// Resolve a revision (oid, ref name, remote ref) to a full commit oid.
    fn resolve(&self, revision: &str) -> Result<String, VcsError>;
    /// History from HEAD, or from every branch, remote branch and tag with
    /// `all_branches`, newest first and each commit before its parents.
    fn log(&self, limit: usize, all_branches: bool) -> Result<Vec<LogEntry>, VcsError>;

    /// The branch a pull request merges into by default, if the repo says.
    fn default_branch(&self, remote: &str) -> Result<Option<String>, VcsError>;

    /// Commits reachable from `head` but not `base`, newest first.
    fn commits_between(&self, base: &str, head: &str) -> Result<Vec<LogEntry>, VcsError>;
    /// Commits on HEAD that no remote-tracking branch contains, newest first,
    /// at most `limit`. `None` when the repo has no remote-tracking refs. We
    /// ask the remotes since a configured upstream may be a local branch or a
    /// stale ref.
    fn unpushed(&self, limit: usize) -> Result<Option<Vec<LogEntry>>, VcsError>;
    /// Last commit to touch each line of `rel` as the worktree has it, in line
    /// order. Uncommitted lines come back as spans owned by no commit.
    fn blame(&self, rel: &Path) -> Result<Vec<BlameSpan>, VcsError>;
    /// [`Vcs::blame`] of `rel` as `rev`'s tree has it.
    fn blame_at(&self, rev: &str, rel: &Path) -> Result<Vec<BlameSpan>, VcsError>;
    /// The commits on HEAD's first-parent line that changed `rel`, newest
    /// first, at most `limit`, following renames like `git log --follow`.
    fn file_history(&self, rel: &str, limit: usize) -> Result<Vec<FileVersion>, VcsError>;

    /// One file's content in `rev`'s tree, `None` when the tree lacks it.
    fn read_at(&self, rev: &str, path: &str) -> Result<Option<String>, VcsError>;

    /// A blob's raw bytes by its hex id (a [`crate::model::BlobIds`] side),
    /// `None` when the object store has no such blob.
    fn read_blob(&self, oid: &str) -> Result<Option<Vec<u8>>, VcsError>;

    /// Every tracked file, repo-relative and sorted. This is the index, so a
    /// staged new file is tracked and an untracked one is not.
    fn tracked_files(&self) -> Result<Vec<PathBuf>, VcsError>;

    /// Whether the repo's git attributes set `name` to true for `rel`.
    /// Unreadable attribute files read as unset.
    fn attr(&self, rel: &Path, name: &str) -> bool;

    /// Local branches, their divergence left unresolved.
    fn branches(&self) -> Result<Vec<BranchInfo>, VcsError>;

    /// `(ahead, behind)` against the upstream, `None` when `branch` tracks
    /// nothing.
    fn divergence(&self, branch: &str) -> Result<Option<(usize, usize)>, VcsError>;
    /// Local and remote-tracking branch names.
    fn all_branches(&self) -> Result<Vec<String>, VcsError>;
    /// Stage a whole file (worktree deletions become staged deletions).
    fn stage(&self, rel: &Path) -> Result<(), VcsError>;

    /// Stage every change in the worktree, deletions and untracked files
    /// included.
    fn stage_everything(&self) -> Result<(), VcsError>;

    /// Reset the whole index back to HEAD, keeping the worktree.
    fn unstage_everything(&self) -> Result<(), VcsError>;
    /// Stage one hunk out of the unstaged (or untracked) changes of a file.
    fn stage_hunk(&self, rel: &Path, hunk: &HunkId) -> Result<(), VcsError>;
    /// Reset a file's index entry back to HEAD, keeping the worktree.
    fn unstage(&self, rel: &Path) -> Result<(), VcsError>;
    /// Remove one staged hunk from the index, keeping the worktree.
    fn unstage_hunk(&self, rel: &Path, hunk: &HunkId) -> Result<(), VcsError>;
    /// Throw away worktree changes only; an untracked file is deleted.
    /// Refused while the file has staged changes (unstage first).
    fn discard(&self, rel: &Path) -> Result<(), VcsError>;
    /// Commit the index; returns the new commit id.
    fn commit(&self, message: &str) -> Result<String, VcsError>;
    /// Commit only `paths` (files or folders) of the working copy, which jj
    /// can do with no index; returns the new commit id.
    fn commit_paths(&self, _message: &str, _paths: &[String]) -> Result<String, VcsError> {
        Err(VcsError::Rejected(
            "stage the files you want, then commit".into(),
        ))
    }
    /// A jj change verb; git has none.
    fn change(&self, _op: &ChangeOp) -> Result<(), VcsError> {
        Err(VcsError::Rejected(JJ_ONLY.into()))
    }
    /// What a jj pull fetches and rebases onto, read fresh; `None` outside
    /// jj or with no local bookmark under `@`.
    fn pull_target(&self) -> Result<Option<PullTarget>, VcsError> {
        Ok(None)
    }
    /// After a fetch, rebase `@`'s branch onto `bookmark@remote` when that
    /// moved.
    fn rebase_onto_remote(&self, _bookmark: &str, _remote: &str) -> Result<PullOutcome, VcsError> {
        Err(VcsError::Rejected(JJ_ONLY.into()))
    }
    /// Full message of the commit `oid`.
    fn commit_message(&self, oid: &str) -> Result<String, VcsError>;
    /// Full message of the HEAD commit, for amend/reword editor templates.
    fn head_message(&self) -> Result<String, VcsError>;
    /// Amend HEAD, returning the new commit id. `message` `None` reuses HEAD's
    /// message (extend); `Some` rewords it. `use_index` true folds the staged
    /// index into the new tree; false keeps HEAD's tree (a pure reword).
    fn amend(&self, message: Option<&str>, use_index: bool) -> Result<String, VcsError>;
    fn create_branch(&self, name: &str, checkout: bool) -> Result<(), VcsError>;
    /// Refused for the currently checked-out branch.
    fn delete_branch(&self, name: &str) -> Result<(), VcsError>;
    fn checkout(&self, name: &str) -> Result<(), VcsError>;
    /// Whether a raw `git`/forge CLI command may check out a branch. False for
    /// jj, whose operation log would miss the write, so we go through
    /// [`Vcs::checkout`] there.
    fn native_git_checkout(&self) -> bool;
    /// Stash tracked changes, leaving untracked files in place like
    /// `git stash`. `message` `None` lets the backend label it.
    fn stash_push(&self, message: Option<&str>) -> Result<(), VcsError>;
    /// Restore the most recent stash and drop it. Refused when there is no
    /// stash or the pop would conflict.
    fn stash_pop(&self) -> Result<(), VcsError>;
    /// Argv for a network op, run in [`Vcs::workdir`] so the backend's own CLI
    /// handles credentials. jj pushes bookmarks with `jj git push` and pulls
    /// with `jj git fetch`, then [`Vcs::rebase_onto_remote`].
    fn network_argv(&self, op: NetworkOp) -> Result<Vec<String>, VcsError>;
    /// The main worktree first, then the linked ones by name.
    fn worktrees(&self) -> Result<Vec<Worktree>, VcsError>;
    fn worktree_argv(&self, op: &WorktreeOp) -> Result<Vec<String>, VcsError>;
    fn workdir(&self) -> Result<PathBuf, VcsError>;
    fn remote_url(&self, name: &str) -> Result<Option<String>, VcsError>;
    fn remotes(&self) -> Result<Vec<String>, VcsError>;

    /// Set the line-diff algorithm for every later diff of this instance.
    fn set_diff_algorithm(&self, algorithm: DiffAlgorithm, indent_heuristic: bool);
}

/// The status screen's commit sections.
#[derive(Debug, Clone, Default, PartialEq, Eq)]
pub struct CommitLists {
    /// [`Vcs::unpushed`]'s answer.
    pub unpushed: Option<Vec<LogEntry>>,
    /// HEAD's history with the unpushed commits left out.
    pub recent: Vec<LogEntry>,
}

/// `recent` leaves out what `unpushed` holds, so a caller applies both or
/// neither, or a commit goes missing from both sections.
pub fn commit_lists(
    vcs: &dyn Vcs,
    recent_limit: usize,
    unpushed_limit: usize,
) -> Result<CommitLists, VcsError> {
    let unpushed = vcs.unpushed(unpushed_limit)?;
    let ahead = unpushed.as_deref().unwrap_or_default();
    let mut recent = vcs.log(recent_limit + ahead.len(), false)?;
    recent.retain(|entry| !ahead.iter().any(|local| local.oid == entry.oid));
    recent.truncate(recent_limit);
    Ok(CommitLists { unpushed, recent })
}

/// Three-dot diff of `rev` against the working tree: `merge-base(rev, HEAD)`
/// vs index + worktree + untracked. Commits `rev` gained since the branch
/// forked stay out of it; uncommitted work stays in.
pub fn against_diff(vcs: &dyn Vcs, rev: &str) -> Result<DiffModel, VcsError> {
    let head = vcs.resolve("HEAD")?;
    let base = vcs.merge_base(&vcs.resolve(rev)?, &head)?;
    vcs.tree_to_workdir_diff(&base)
}