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
//! Backend-agnostic VCS interface. Everything above this trait consumes
//! `dyn Vcs`; only the `git` and `repo` modules (and test fixtures) may
//! import git2.
use std::path::{Path, PathBuf};
use thiserror::Error;
use crate::model::{DiffModel, HunkId};
#[derive(Debug, Error)]
pub enum VcsError {
// acceptable for M1; rework when a second backend lands
#[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, so work that exists only here is
/// visible without running `git status`. 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>,
pub subject: String,
pub author: String,
pub time_unix: i64,
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct BranchInfo {
pub name: String,
pub is_head: bool,
/// Tip commit's time as a Unix timestamp, so callers can sort branches
/// newest-first and render an age.
pub tip_unix: i64,
/// How far this branch stands from its upstream, as `(ahead, behind)`.
/// Resolving one costs a config read and a graph walk, so a listing leaves
/// it `None` and the caller asks [`Vcs::divergence`] for the branches it
/// actually shows.
pub divergence: Option<(usize, usize)>,
}
/// A network operation the binary runs by shelling out to the backend's CLI,
/// so the user's existing auth (SSH agent, credential helper, tokens) applies
/// without diffler holding any credentials.
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum NetworkOp {
Fetch,
FetchAll,
}
/// 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 repository metadata directory. In a plain repo this is
/// `<root>/.git`; in a linked worktree `<root>/.git` is a gitlink file
/// and this resolves to the external gitdir it points at.
fn git_dir(&self) -> Result<PathBuf, VcsError>;
/// Current branch, commit, and upstream.
fn head(&self) -> Result<HeadInfo, VcsError>;
/// Untracked / unstaged / staged sections as separate diff models.
fn status(&self) -> Result<StatusModel, VcsError>;
/// HEAD vs workdir+index including untracked files: the review view.
fn working_tree_diff(&self) -> Result<DiffModel, VcsError>;
/// [`Vcs::working_tree_diff`] taken from an arbitrary base commit instead
/// of HEAD, so uncommitted work shows alongside the commits since `base`.
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>;
/// Combined diff of a contiguous commit range, from the first parent of
/// `oldest` to `newest` (`git diff <oldest>^..<newest>` semantics). When
/// `oldest` is a root commit its first-parent tree is the empty tree, so
/// the range includes everything `oldest` introduced.
fn range_diff(&self, oldest_oid: &str, newest_oid: &str) -> Result<DiffModel, VcsError>;
/// Diff between two trees as-is (`git diff <base> <newest>` semantics);
/// with `base` a merge base this is a PR-style three-dot diff.
fn tree_diff(&self, base_oid: &str, newest_oid: &str) -> Result<DiffModel, VcsError>;
/// Best common ancestor of two commits.
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, newest first.
fn log(&self, limit: usize) -> Result<Vec<LogEntry>, VcsError>;
/// The branch a pull request merges into by default; `None` when the
/// repository offers no answer.
fn default_branch(&self, remote: &str) -> Result<Option<String>, VcsError>;
/// Commits reachable from `head` but not `base`, newest first: what a
/// pull request from `head` would carry.
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` of them: work that exists only on this machine. `None`
/// when the repository has no remote-tracking refs, where being pushed has
/// no meaning yet. Asking the remotes rather than the configured upstream
/// is what makes the answer true: an upstream may be another local branch,
/// or a stale ref from before the last fetch. `limit` bounds a walk that is
/// otherwise the whole history whenever no remote ref sits on it.
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. Lines the worktree added since the last commit come back as one
/// span of their own, owned by no commit.
fn blame(&self, rel: &Path) -> Result<Vec<BlameSpan>, VcsError>;
/// One file's content as recorded in `rev`'s tree, `None` when that tree
/// has no such path. Lets a review pinned to one tree (a commit, a
/// range's newest, a PR's head) resolve a walkthrough's anchors against
/// what it actually shows, rather than whatever the worktree holds now.
fn read_at(&self, rev: &str, path: &str) -> Result<Option<String>, 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: the caller is refining a
/// guess, so there is nothing to report and nothing to recover.
fn attr(&self, rel: &Path, name: &str) -> bool;
/// Local branches, their divergence left unresolved.
fn branches(&self) -> Result<Vec<BranchInfo>, VcsError>;
/// How far `branch` stands from its upstream, as `(ahead, behind)`, or
/// `None` when it tracks nothing.
fn divergence(&self, branch: &str) -> Result<Option<(usize, usize)>, VcsError>;
/// Local and remote-tracking branch names, for pickers that name a
/// revision rather than check one out.
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. Resolved against the repository as it is now, so a file
/// edited since the caller last looked is still caught.
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>;
/// 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 (extend/amend); false keeps HEAD's tree (a
/// pure reword). Local-only: no network.
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>;
/// Stash tracked changes (staged + unstaged), reverting the worktree to
/// HEAD; untracked files are left in place, matching `git stash`. `message`
/// `None` lets the backend label it. Local-only: no network.
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 to run for a network op, e.g. `["git", "push"]`. The binary runs
/// this in [`Vcs::workdir`] so the backend's own CLI handles credentials;
/// diffler never touches them. A future jj backend returns `["jj", …]`.
fn network_argv(&self, op: NetworkOp) -> Vec<String>;
/// Working directory to run [`Vcs::network_argv`] in.
fn workdir(&self) -> Result<PathBuf, VcsError>;
/// URL of the named remote (e.g. `origin`), if it exists. Used to detect the
/// CI provider's host without shelling out.
fn remote_url(&self, name: &str) -> Result<Option<String>, VcsError>;
/// Names of every configured remote, for multi-remote CI detection.
fn remotes(&self) -> Result<Vec<String>, VcsError>;
}
/// 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)
}