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
use crate::fleet::error::{Result, SuperGitError};
use crate::git_backend;
use crate::prelude::*;
pub struct GitOps;
impl GitOps {
/// Discover the worktree root for the repository containing `path`.
pub fn discover_repo_root<P: AsRef<Path>>(path: P) -> Result<PathBuf> {
git_backend::repo_root(path.as_ref()).map_err(|_| SuperGitError::NotARepository)
}
/// Open a repository at the given path
pub fn open_repo<P: AsRef<Path>>(path: P) -> Result<gix::Repository> {
git_backend::open_repo(path.as_ref()).map_err(|_| SuperGitError::NotARepository)
}
/// Get the URL for a named remote.
pub fn remote_url(repo: &gix::Repository, remote_name: &str) -> Result<String> {
git_backend::remote_url(repo, remote_name).map_err(Into::into)
}
/// Get current branch name
pub fn current_branch(repo: &gix::Repository) -> Result<String> {
git_backend::current_branch(repo).map_err(Into::into)
}
/// Check if working directory is clean
pub fn is_clean(repo: &gix::Repository) -> Result<bool> {
git_backend::is_clean(repo).map_err(Into::into)
}
/// Get list of uncommitted files
///
/// A repository is clean exactly when this is empty, so a caller that needs
/// both facts should ask this once rather than pairing it with
/// [`is_clean`](Self::is_clean) — walking a large worktree twice is the
/// expensive part.
pub fn get_uncommitted_files(repo: &gix::Repository) -> Result<Vec<String>> {
Ok(git_backend::status_entries(repo)?
.into_iter()
.filter(|entry| entry.status != git_backend::FileStatusKind::Ignored)
.map(|entry| entry.path)
.collect())
}
/// Uncommitted changes, with moved submodule pointers separated from the
/// repository's own files: `(own files, moved gitlinks)`.
///
/// A container repository in this fleet holds little but gitlinks, and they
/// move whenever a child's working tree does — which, in a tree under
/// `wsb local on`, is every child at once. Counted as ordinary changes they
/// report `cartridges` as eleven uncommitted files when `git status
/// --ignore-submodules=all` there shows nothing, and the number tracks the
/// render rather than anything somebody did.
///
/// They are separated rather than dropped, because a moved gitlink IS a
/// pending change — the commit recording where the children sit has not been
/// made — and a status that hid it would misdescribe what a commit from this
/// tree would contain. One walk answers both, since walking a large worktree
/// twice is the expensive part.
pub fn uncommitted_files_and_pointers(
repo: &gix::Repository,
) -> Result<(Vec<String>, Vec<String>)> {
let (pointers, own): (Vec<_>, Vec<_>) = git_backend::status_entries(repo)?
.into_iter()
.filter(|entry| entry.status != git_backend::FileStatusKind::Ignored)
.partition(|entry| entry.submodule_pointer);
Ok((
own.into_iter().map(|entry| entry.path).collect(),
pointers.into_iter().map(|entry| entry.path).collect(),
))
}
/// Uncommitted changes to this repository's OWN files.
///
/// A moved gitlink is left out. It is a real pending change — the commit
/// that records where the children sit has not been made — but it is not an
/// edit anyone can lose, and for a container repository in this fleet it is
/// the ordinary state: the children are pulled by the same command that
/// pulls the container, so their positions differ from the container's HEAD
/// for most of a fleet pull.
///
/// Treating it as "uncommitted changes" made `pull` skip every container
/// whose children had moved — `fabricloom` reporting `floom-engine,
/// floom-uikit, macfloom, and 1 more` while `git status
/// --ignore-submodules=all` reported nothing at all — so the container's own
/// commits were never pulled, and the next run skipped it for the same
/// reason.
///
/// Nothing is waved through: if the incoming commits really would overwrite
/// something, the pull itself reports it, from the one place that can see
/// what the merge actually touches.
pub fn get_uncommitted_own_files(repo: &gix::Repository) -> Result<Vec<String>> {
Ok(git_backend::status_entries(repo)?
.into_iter()
.filter(|entry| entry.status != git_backend::FileStatusKind::Ignored)
.filter(|entry| !entry.submodule_pointer)
.map(|entry| entry.path)
.collect())
}
/// Name what makes a repository dirty, for a message the reader can act on.
///
/// Capped so one noisy tree cannot bury the rest of a run's output.
pub fn describe_uncommitted(paths: &[String]) -> String {
const SHOWN: usize = 3;
if paths.len() <= SHOWN {
paths.join(", ")
} else {
format!(
"{}, and {} more",
paths[..SHOWN].join(", "),
paths.len() - SHOWN
)
}
}
/// Check if branch exists locally
pub fn branch_exists(repo: &gix::Repository, branch_name: &str) -> Result<bool> {
git_backend::branch_exists(repo, branch_name, git_backend::BranchKind::Local)
.map_err(Into::into)
}
/// Check if remote branch exists
pub fn remote_branch_exists(repo: &gix::Repository, branch_name: &str) -> Result<bool> {
let remote_branch = format!("origin/{branch_name}");
git_backend::branch_exists(repo, &remote_branch, git_backend::BranchKind::Remote)
.map_err(Into::into)
}
/// Get ahead/behind counts for current branch
pub fn get_ahead_behind(repo: &gix::Repository) -> Result<(usize, usize)> {
let head = repo.head().map_err(anyhow::Error::from)?;
let branch = match head.referent_name() {
Some(name) => name,
None => return Ok((0, 0)),
};
let tracking =
match repo.branch_remote_tracking_ref_name(branch, gix::remote::Direction::Fetch) {
Some(Ok(name)) => name,
Some(Err(err)) => return Err(anyhow::Error::from(err).into()),
None => return Ok((0, 0)),
};
let head_id = repo.head_id().map_err(anyhow::Error::from)?.detach();
let upstream_id = match repo.find_reference(tracking.as_ref()) {
Ok(mut reference) => reference
.peel_to_id()
.map_err(anyhow::Error::from)?
.detach(),
Err(_) => return Ok((0, 0)),
};
let merge_base = repo
.merge_base(head_id, upstream_id)
.map_err(anyhow::Error::from)?;
let ahead = count_commits_between(repo, head_id, merge_base.into())?;
let behind = count_commits_between(repo, upstream_id, merge_base.into())?;
Ok((ahead, behind))
}
/// Fetch from remote
pub fn fetch(repo: &gix::Repository, remote_name: &str) -> Result<()> {
git_backend::fetch(repo, Some(remote_name)).map_err(Into::into)
}
/// Check if repository is in detached HEAD state
pub fn is_detached_head(repo: &gix::Repository) -> Result<bool> {
git_backend::is_detached_head(repo).map_err(Into::into)
}
/// Get list of all local branches
pub fn get_local_branches(repo: &gix::Repository) -> Result<Vec<String>> {
git_backend::local_branches(repo).map_err(Into::into)
}
/// Get list of all remote branches
pub fn get_remote_branches(repo: &gix::Repository) -> Result<Vec<String>> {
git_backend::remote_branches(repo).map_err(Into::into)
}
/// Get the default branch name (main or master)
pub fn get_default_branch(repo: &gix::Repository) -> Result<String> {
git_backend::get_default_branch(repo).map_err(Into::into)
}
}
fn count_commits_between(
repo: &gix::Repository,
from: gix::ObjectId,
stop_at: gix::ObjectId,
) -> Result<usize> {
let commit = repo.find_commit(from).map_err(anyhow::Error::from)?;
let mut count = 0usize;
for ancestor in commit.ancestors().all().map_err(anyhow::Error::from)? {
let ancestor = ancestor.map_err(anyhow::Error::from)?;
if ancestor.id == stop_at {
break;
}
count += 1;
}
Ok(count)
}