diffler_core/vcs.rs
1//! Backend-agnostic VCS interface. Only the `git` and `repo` modules and test
2//! fixtures may import git2.
3
4use std::path::{Path, PathBuf};
5
6use thiserror::Error;
7
8use crate::diffalgo::DiffAlgorithm;
9use crate::model::{DiffModel, HunkId};
10
11#[derive(Debug, Error)]
12pub enum VcsError {
13 #[error(transparent)]
14 Git(#[from] git2::Error),
15 #[error("repository has no working directory")]
16 NoWorkdir,
17 /// Domain refusal, e.g. discarding a file with staged changes.
18 #[error("{0}")]
19 Rejected(String),
20 #[error(transparent)]
21 Io(#[from] std::io::Error),
22}
23
24#[derive(Debug, Clone, PartialEq, Eq)]
25pub struct HeadInfo {
26 /// Branch shorthand; `None` when HEAD is detached.
27 pub branch: Option<String>,
28 /// Abbreviated commit id; empty on an unborn branch.
29 pub oid7: String,
30 /// First line of the HEAD commit message; empty on an unborn branch.
31 pub subject: String,
32 /// Upstream branch shorthand, if configured.
33 pub upstream: Option<String>,
34 /// Commits on HEAD the upstream lacks. Zero without an upstream.
35 pub ahead: usize,
36 /// Commits on the upstream that HEAD lacks.
37 pub behind: usize,
38}
39
40#[derive(Debug, Clone, PartialEq, Eq)]
41pub struct LogEntry {
42 pub oid: String,
43 pub oid7: String,
44 /// Shorthand names of references pointing at this commit.
45 pub refs: Vec<String>,
46 pub subject: String,
47 pub author: String,
48 pub time_unix: i64,
49}
50
51#[derive(Debug, Clone, PartialEq, Eq)]
52pub struct BranchInfo {
53 pub name: String,
54 pub is_head: bool,
55 pub tip_unix: i64,
56 /// `(ahead, behind)` against the upstream. Resolving one costs a graph
57 /// walk, so listings leave it `None` and callers ask
58 /// [`Vcs::divergence`] for the branches they show.
59 pub divergence: Option<(usize, usize)>,
60}
61
62/// A network operation we run through the backend's CLI, so the user's own
63/// auth applies and diffler holds no credentials.
64#[derive(Debug, Clone, PartialEq, Eq)]
65pub enum NetworkOp {
66 Fetch,
67 FetchAll,
68 Push,
69 PushSetUpstream { remote: String },
70 Pull,
71 PullFrom { remote: String, branch: String },
72 PullRebase,
73 PullMerge,
74}
75
76/// A run of consecutive lines a single commit last touched.
77#[derive(Debug, Clone, PartialEq, Eq)]
78pub struct BlameSpan {
79 /// 1-based first line of the run in the blamed content.
80 pub start_line: u32,
81 pub line_count: u32,
82 pub oid: String,
83 pub oid7: String,
84 pub author: String,
85 pub time_unix: i64,
86 pub summary: String,
87 /// False for lines that exist only in the worktree, which no commit owns.
88 pub committed: bool,
89}
90
91/// Per-area views of the working tree, neogit-style sections.
92#[derive(Debug, Clone, Default)]
93pub struct StatusModel {
94 pub untracked: DiffModel,
95 pub unstaged: DiffModel,
96 pub staged: DiffModel,
97}
98
99pub trait Vcs: Send {
100 /// Resolved metadata directory. In a linked worktree this is the gitdir
101 /// the `.git` file points at.
102 fn git_dir(&self) -> Result<PathBuf, VcsError>;
103 fn head(&self) -> Result<HeadInfo, VcsError>;
104 /// Whether this backend has a staging area. jj has none, so its whole
105 /// working copy reads as [`StatusModel::staged`].
106 fn has_index(&self) -> bool;
107 fn status(&self) -> Result<StatusModel, VcsError>;
108 /// HEAD vs index + worktree, untracked files included.
109 fn working_tree_diff(&self) -> Result<DiffModel, VcsError>;
110 /// [`Vcs::working_tree_diff`] taken from `base_oid`.
111 fn tree_to_workdir_diff(&self, base_oid: &str) -> Result<DiffModel, VcsError>;
112 /// Changes a single commit introduced over its first parent.
113 fn commit_diff(&self, oid: &str) -> Result<DiffModel, VcsError>;
114 /// `git diff <oldest>^..<newest>`. A root `oldest` diffs from the empty
115 /// tree.
116 fn range_diff(&self, oldest_oid: &str, newest_oid: &str) -> Result<DiffModel, VcsError>;
117 /// `git diff <base> <newest>`; with `base` a merge base this is a
118 /// three-dot diff.
119 fn tree_diff(&self, base_oid: &str, newest_oid: &str) -> Result<DiffModel, VcsError>;
120 fn merge_base(&self, a: &str, b: &str) -> Result<String, VcsError>;
121 /// Resolve a revision (oid, ref name, remote ref) to a full commit oid.
122 fn resolve(&self, revision: &str) -> Result<String, VcsError>;
123 /// History from HEAD, newest first.
124 fn log(&self, limit: usize) -> Result<Vec<LogEntry>, VcsError>;
125
126 /// The branch a pull request merges into by default, if the repo says.
127 fn default_branch(&self, remote: &str) -> Result<Option<String>, VcsError>;
128
129 /// Commits reachable from `head` but not `base`, newest first.
130 fn commits_between(&self, base: &str, head: &str) -> Result<Vec<LogEntry>, VcsError>;
131 /// Commits on HEAD that no remote-tracking branch contains, newest first,
132 /// at most `limit`. `None` when the repo has no remote-tracking refs. We
133 /// ask the remotes since a configured upstream may be a local branch or a
134 /// stale ref.
135 fn unpushed(&self, limit: usize) -> Result<Option<Vec<LogEntry>>, VcsError>;
136 /// Last commit to touch each line of `rel` as the worktree has it, in line
137 /// order. Uncommitted lines come back as spans owned by no commit.
138 fn blame(&self, rel: &Path) -> Result<Vec<BlameSpan>, VcsError>;
139
140 /// One file's content in `rev`'s tree, `None` when the tree lacks it.
141 fn read_at(&self, rev: &str, path: &str) -> Result<Option<String>, VcsError>;
142
143 /// A blob's raw bytes by its hex id (a [`crate::model::BlobIds`] side),
144 /// `None` when the object store has no such blob.
145 fn read_blob(&self, oid: &str) -> Result<Option<Vec<u8>>, VcsError>;
146
147 /// Every tracked file, repo-relative and sorted. This is the index, so a
148 /// staged new file is tracked and an untracked one is not.
149 fn tracked_files(&self) -> Result<Vec<PathBuf>, VcsError>;
150
151 /// Whether the repo's git attributes set `name` to true for `rel`.
152 /// Unreadable attribute files read as unset.
153 fn attr(&self, rel: &Path, name: &str) -> bool;
154
155 /// Local branches, their divergence left unresolved.
156 fn branches(&self) -> Result<Vec<BranchInfo>, VcsError>;
157
158 /// `(ahead, behind)` against the upstream, `None` when `branch` tracks
159 /// nothing.
160 fn divergence(&self, branch: &str) -> Result<Option<(usize, usize)>, VcsError>;
161 /// Local and remote-tracking branch names.
162 fn all_branches(&self) -> Result<Vec<String>, VcsError>;
163 /// Stage a whole file (worktree deletions become staged deletions).
164 fn stage(&self, rel: &Path) -> Result<(), VcsError>;
165
166 /// Stage every change in the worktree, deletions and untracked files
167 /// included.
168 fn stage_everything(&self) -> Result<(), VcsError>;
169
170 /// Reset the whole index back to HEAD, keeping the worktree.
171 fn unstage_everything(&self) -> Result<(), VcsError>;
172 /// Stage one hunk out of the unstaged (or untracked) changes of a file.
173 fn stage_hunk(&self, rel: &Path, hunk: &HunkId) -> Result<(), VcsError>;
174 /// Reset a file's index entry back to HEAD, keeping the worktree.
175 fn unstage(&self, rel: &Path) -> Result<(), VcsError>;
176 /// Remove one staged hunk from the index, keeping the worktree.
177 fn unstage_hunk(&self, rel: &Path, hunk: &HunkId) -> Result<(), VcsError>;
178 /// Throw away worktree changes only; an untracked file is deleted.
179 /// Refused while the file has staged changes (unstage first).
180 fn discard(&self, rel: &Path) -> Result<(), VcsError>;
181 /// Commit the index; returns the new commit id.
182 fn commit(&self, message: &str) -> Result<String, VcsError>;
183 /// Full message of the HEAD commit, for amend/reword editor templates.
184 fn head_message(&self) -> Result<String, VcsError>;
185 /// Amend HEAD, returning the new commit id. `message` `None` reuses HEAD's
186 /// message (extend); `Some` rewords it. `use_index` true folds the staged
187 /// index into the new tree; false keeps HEAD's tree (a pure reword).
188 fn amend(&self, message: Option<&str>, use_index: bool) -> Result<String, VcsError>;
189 fn create_branch(&self, name: &str, checkout: bool) -> Result<(), VcsError>;
190 /// Refused for the currently checked-out branch.
191 fn delete_branch(&self, name: &str) -> Result<(), VcsError>;
192 fn checkout(&self, name: &str) -> Result<(), VcsError>;
193 /// Whether a raw `git`/forge CLI command may check out a branch. False for
194 /// jj, whose operation log would miss the write, so we go through
195 /// [`Vcs::checkout`] there.
196 fn native_git_checkout(&self) -> bool;
197 /// Stash tracked changes, leaving untracked files in place like
198 /// `git stash`. `message` `None` lets the backend label it.
199 fn stash_push(&self, message: Option<&str>) -> Result<(), VcsError>;
200 /// Restore the most recent stash and drop it. Refused when there is no
201 /// stash or the pop would conflict.
202 fn stash_pop(&self) -> Result<(), VcsError>;
203 /// Argv for a network op, run in [`Vcs::workdir`] so the backend's own CLI
204 /// handles credentials. jj rejects push and pull, since they would move
205 /// git's refs behind its back.
206 fn network_argv(&self, op: NetworkOp) -> Result<Vec<String>, VcsError>;
207 fn workdir(&self) -> Result<PathBuf, VcsError>;
208 fn remote_url(&self, name: &str) -> Result<Option<String>, VcsError>;
209 fn remotes(&self) -> Result<Vec<String>, VcsError>;
210
211 /// Set the line-diff algorithm for every later diff of this instance.
212 fn set_diff_algorithm(&self, algorithm: DiffAlgorithm, indent_heuristic: bool);
213}
214
215/// Three-dot diff of `rev` against the working tree: `merge-base(rev, HEAD)`
216/// vs index + worktree + untracked. Commits `rev` gained since the branch
217/// forked stay out of it; uncommitted work stays in.
218pub fn against_diff(vcs: &dyn Vcs, rev: &str) -> Result<DiffModel, VcsError> {
219 let head = vcs.resolve("HEAD")?;
220 let base = vcs.merge_base(&vcs.resolve(rev)?, &head)?;
221 vcs.tree_to_workdir_diff(&base)
222}