Move a project's checkout to a branch or commit from the phone
The project settings sheet now lists the checkout's branches and its last fifty commits, and picking one moves the checkout on the build machine and builds what that leaves behind. It is the same act as a pull on the same single checkout, so it goes through the same machinery and reports in the same place: `after_moving` is the half the two share, which is not the git command but handing the run over under one lock, so nothing can observe the moment between the move ending and the builds it decided on starting. The two lists are read when the sheet opens rather than riding on the manifest, since both spawn git and the manifest is fetched on every open, resume and Refresh. Local reads only, so opening the sheet cannot stall on a network round trip: what the remote has and this checkout has not fetched is Pull's business, and the card already says when there is any. Listing branches was wrong twice in ways only a real checkout showed. `git branch --format` adds a `(HEAD detached at abc123)` pseudo-entry that is not a branch and cannot be checked out, so the list comes from `for-each-ref refs/heads`. And `refs/remotes/origin/HEAD` abbreviates to a bare `origin`, so filtering names that end in `/HEAD` matches nothing and a phantom branch called `origin` reached the phone; it is dropped by being a symref instead. The test written to cover the second asserted the same wrong thing and passed. Moving is refused over tracked modifications only, where a pull refuses over any dirt at all. The strict check makes this a one-way door: going back to a commit from before the .gitignore that covers this project's build output leaves that output untracked, so the tree is dirty and every move afterwards is refused -- back but never forward, from a phone, with the way out being the build machine. Nothing is lost by relaxing it, because git refuses to overwrite an untracked file itself and carries across the ones it would not, and its refusal arrives as the error the card already shows. Found by moving a checkout back and forth rather than by reading it: the first version passed its tests and trapped the checkout on the second move. Picking a commit leaves the checkout on no branch, which the card now says in those words rather than showing git's literal `HEAD` -- beside a branch icon that reads as a branch somebody named HEAD. Worth saying now that the sheet can produce the state, where before it was reachable only on the build machine. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
1 parent
17d873ab7c
commit
3aa6f2f290
7 files changed
+841
-7
No files matched your search
@@ -694,6 +694,58 @@ impl BuildState {
|
||||
may_build: impl Fn() -> bool + Send + 'static,
|
||||
record: RecordBuilt,
|
||||
) {
|
||||
self.after_moving(move |this| this.pull(force), may_build, record);
|
||||
}
|
||||
|
||||
/// Moves this checkout onto `target` -- a branch or a commit -- and
|
||||
/// then builds whatever that left behind.
|
||||
///
|
||||
/// Exclusive with everything, and deferred to the same machinery as a
|
||||
/// pull, because it is the same kind of act: there is one checkout,
|
||||
/// and this rewrites the files every component builds from. It
|
||||
/// reports through the project's own state for the same reason, so a
|
||||
/// card shows it exactly as it shows a pull.
|
||||
pub fn checkout_and_build(
|
||||
self: &Arc<Self>,
|
||||
target: String,
|
||||
may_build: impl Fn() -> bool + Send + 'static,
|
||||
record: RecordBuilt,
|
||||
) {
|
||||
self.after_moving(
|
||||
move |this| {
|
||||
this.begin_project("checking out");
|
||||
crate::git::checkout(&this.project_path, &target)?;
|
||||
tracing::info!("checked out {target} in {}", this.project_path.display());
|
||||
// Always "something moved": a checkout onto the commit
|
||||
// that was already there is the one case this over-reports,
|
||||
// and the cost of that is one build that had nothing to do.
|
||||
// Under-reporting costs a checkout whose outputs stay from
|
||||
// the commit before it, which is the silent kind.
|
||||
Ok(true)
|
||||
},
|
||||
may_build,
|
||||
record,
|
||||
);
|
||||
}
|
||||
|
||||
/// The half a pull and a checkout share: run the thing that moves the
|
||||
/// working tree, then build what it left behind.
|
||||
///
|
||||
/// One copy, because the part that is easy to get wrong is not the
|
||||
/// git command -- it is handing the run over under a single lock so
|
||||
/// that nothing can observe the moment between the move ending and
|
||||
/// the builds it decided on starting. A phone polling in that gap
|
||||
/// sees a project that is neither pulling nor building and calls the
|
||||
/// run finished.
|
||||
fn after_moving(
|
||||
self: &Arc<Self>,
|
||||
move_it: impl FnOnce(&Arc<Self>) -> Result<bool, crate::git::PullError> + Send + 'static,
|
||||
may_build: impl Fn() -> bool + Send + 'static,
|
||||
record: RecordBuilt,
|
||||
) {
|
||||
// Claimed before anything is spawned, and while nothing else is
|
||||
// running: one checkout, so a move is exclusive with every build
|
||||
// as well as with another move.
|
||||
{
|
||||
let mut inner = self.inner.lock().unwrap();
|
||||
if inner.anything_running() {
|
||||
@@ -703,13 +755,12 @@ impl BuildState {
|
||||
inner.error = None;
|
||||
inner.unrelated_histories = false;
|
||||
}
|
||||
|
||||
let this = Arc::clone(self);
|
||||
tokio::task::spawn_blocking(move || {
|
||||
let outcome = this.pull(force);
|
||||
let outcome = move_it(&this);
|
||||
match outcome {
|
||||
Err(error) => {
|
||||
tracing::error!("pull failed: {}", error.message);
|
||||
tracing::error!("moving the checkout failed: {}", error.message);
|
||||
this.fail_pull(error);
|
||||
}
|
||||
Ok(pulled) => {
|
||||
|
||||
@@ -300,6 +300,215 @@ impl From<String> for PullError {
|
||||
}
|
||||
}
|
||||
|
||||
/// A branch this checkout could be moved to.
|
||||
#[derive(Debug, Clone, serde::Serialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct Branch {
|
||||
/// What to hand [`checkout`], and what a person reads: `main`, or
|
||||
/// `origin/topic` for one that exists only on the remote.
|
||||
pub name: String,
|
||||
/// This is the branch that is checked out. False for every branch
|
||||
/// while HEAD is detached, which is a state the sheet has to be able
|
||||
/// to draw rather than pick a branch to call current.
|
||||
pub current: bool,
|
||||
/// It exists only as a remote-tracking ref, so moving to it creates a
|
||||
/// local branch. Said on the phone because it is the difference
|
||||
/// between returning to a branch and starting one.
|
||||
pub remote_only: bool,
|
||||
}
|
||||
|
||||
/// One commit, as a line to pick from.
|
||||
#[derive(Debug, Clone, serde::Serialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct CommitSummary {
|
||||
/// The full hash, which is what [`checkout`] is given -- an
|
||||
/// abbreviation is for reading, and can become ambiguous as the
|
||||
/// repository grows.
|
||||
pub sha: String,
|
||||
/// The abbreviation to show, from git rather than by truncating the
|
||||
/// hash here: git widens it when a repository needs more characters,
|
||||
/// and a fixed slice would eventually name two commits.
|
||||
pub short: String,
|
||||
/// The first line of the message.
|
||||
pub subject: String,
|
||||
/// Author date, epoch seconds, for the phone to render in its own
|
||||
/// locale rather than this machine's.
|
||||
pub at: i64,
|
||||
/// This is where HEAD is. What the sheet marks as selected, and the
|
||||
/// reason it never has to guess from a position in the list.
|
||||
pub current: bool,
|
||||
}
|
||||
|
||||
/// Every branch worth offering: the local ones, plus remote-tracking refs
|
||||
/// with no local branch of the same name.
|
||||
///
|
||||
/// A remote-only branch is included because "switch to the branch I
|
||||
/// pushed from the other machine" is most of what this is for, and
|
||||
/// leaving it out would mean the only way to reach it is a terminal on
|
||||
/// the build machine -- which is exactly where the person holding the
|
||||
/// phone is not.
|
||||
///
|
||||
/// Never on the manifest path: this spawns git twice and is read when the
|
||||
/// settings sheet is opened, which is somebody asking.
|
||||
pub fn branches(project: &Path) -> Vec<Branch> {
|
||||
let head = status(project).map(|status| status.branch);
|
||||
// Detached HEAD reports the literal `HEAD`, which is not a branch
|
||||
// name, so nothing matches it and every branch reads as not current.
|
||||
let current = head.filter(|branch| branch != "HEAD");
|
||||
|
||||
let mut branches: Vec<Branch> = Vec::new();
|
||||
// `for-each-ref refs/heads` rather than `git branch`, which adds a
|
||||
// pseudo-entry for the detached state -- `(HEAD detached at cfdce91)`
|
||||
// reached the phone as a branch you could try to check out. Found by
|
||||
// detaching a real checkout and looking at the list, which is the
|
||||
// state the feature exists to produce and so the one to look at.
|
||||
if let Ok(out) = git(
|
||||
project,
|
||||
&["for-each-ref", "--format=%(refname:short)", "refs/heads"],
|
||||
) {
|
||||
for name in out.lines().map(str::trim).filter(|name| !name.is_empty()) {
|
||||
branches.push(Branch {
|
||||
current: current.as_deref() == Some(name),
|
||||
name: name.to_string(),
|
||||
remote_only: false,
|
||||
});
|
||||
}
|
||||
}
|
||||
if let Ok(out) = git(
|
||||
project,
|
||||
&[
|
||||
"for-each-ref",
|
||||
// A space, because `for-each-ref` does not expand `%xx` the
|
||||
// way `log` does -- and a ref name cannot contain one, so
|
||||
// there is nothing for it to split by mistake.
|
||||
"--format=%(refname:short) %(symref)",
|
||||
"refs/remotes",
|
||||
],
|
||||
) {
|
||||
for line in out.lines().map(str::trim).filter(|line| !line.is_empty()) {
|
||||
let (name, symref) = line.split_once(' ').unwrap_or((line, ""));
|
||||
// `refs/remotes/origin/HEAD` is a symbolic ref at whatever the
|
||||
// remote calls its default branch, and offering it would offer
|
||||
// that branch a second time. Detected by *being* a symref
|
||||
// rather than by its name, because `%(refname:short)`
|
||||
// abbreviates it to a bare `origin` -- so the obvious check for
|
||||
// a name ending in `/HEAD` matches nothing, and a phantom
|
||||
// branch called `origin` is what reaches the phone. Found by
|
||||
// running it against a real clone; the test that was supposed
|
||||
// to cover it asserted the same wrong thing and passed.
|
||||
if !symref.is_empty() {
|
||||
continue;
|
||||
}
|
||||
// `origin/main` when `main` is already local is the same
|
||||
// branch to a reader, and picking the remote one would detach
|
||||
// from the branch they are on.
|
||||
let local = name.split_once('/').map(|(_, rest)| rest).unwrap_or(name);
|
||||
if branches.iter().any(|branch| branch.name == local) {
|
||||
continue;
|
||||
}
|
||||
branches.push(Branch {
|
||||
name: name.to_string(),
|
||||
current: false,
|
||||
remote_only: true,
|
||||
});
|
||||
}
|
||||
}
|
||||
branches
|
||||
}
|
||||
|
||||
/// The most recent `limit` commits reachable from HEAD, newest first.
|
||||
///
|
||||
/// Local history only. What the remote has and this checkout has not
|
||||
/// fetched is not offered: Pull is what brings those in, and the card
|
||||
/// already says when there are some -- so this cannot stall on a network
|
||||
/// round trip, and opening the sheet cannot fail the way a remote check
|
||||
/// can.
|
||||
pub fn recent_commits(project: &Path, limit: usize) -> Vec<CommitSummary> {
|
||||
// A record separator that cannot occur in a subject, so a message
|
||||
// containing anything at all still parses. `%x1f` is the ASCII unit
|
||||
// separator; git writes it literally.
|
||||
let format = "--format=%H%x1f%h%x1f%at%x1f%s";
|
||||
let head = git(project, &["rev-parse", "HEAD"])
|
||||
.ok()
|
||||
.map(|sha| sha.trim().to_string());
|
||||
let Ok(out) = git(
|
||||
project,
|
||||
&["log", &format!("-n{limit}"), format, "--no-color"],
|
||||
) else {
|
||||
return Vec::new();
|
||||
};
|
||||
out.lines()
|
||||
.filter_map(|line| {
|
||||
let mut parts = line.splitn(4, '\u{1f}');
|
||||
let sha = parts.next()?.to_string();
|
||||
let short = parts.next()?.to_string();
|
||||
let at = parts.next()?.parse().unwrap_or(0);
|
||||
// A commit with an empty subject is legal, so the last field
|
||||
// is allowed to be missing rather than dropping the commit.
|
||||
let subject = parts.next().unwrap_or_default().to_string();
|
||||
Some(CommitSummary {
|
||||
current: head.as_deref() == Some(sha.as_str()),
|
||||
sha,
|
||||
short,
|
||||
at,
|
||||
subject,
|
||||
})
|
||||
})
|
||||
.collect()
|
||||
}
|
||||
|
||||
/// Moves this checkout onto `target`, a branch name or a commit.
|
||||
///
|
||||
/// Refuses a dirty tree for the same reason [`pull`] does, and it matters
|
||||
/// more here: a checkout can move *backwards*, so the work it would
|
||||
/// clobber may not exist anywhere else yet. The failure leaves the
|
||||
/// working tree exactly as it was.
|
||||
///
|
||||
/// A remote-only branch is checked out by name, which git resolves to a
|
||||
/// new local branch tracking it -- the "switch to what I pushed from the
|
||||
/// other machine" case. A commit detaches HEAD, which is a real state the
|
||||
/// card already draws: `status` reports the branch as `HEAD`, `can_pull`
|
||||
/// goes false because there is no upstream, and the Pull button correctly
|
||||
/// stops being offered until a branch is chosen again.
|
||||
pub fn checkout(project: &Path, target: &str) -> Result<(), PullError> {
|
||||
if status(project).is_none() {
|
||||
return Err("not a git repository".to_string().into());
|
||||
}
|
||||
// **Tracked** modifications only, unlike the check in front of a pull.
|
||||
//
|
||||
// The difference is not a relaxation for its own sake; the strict
|
||||
// version makes this feature a one-way door. Moving back to a commit
|
||||
// from before a `.gitignore` existed -- or one that ignored a
|
||||
// different directory -- leaves the build output this server just
|
||||
// produced sitting there *untracked*, so the tree is dirty and every
|
||||
// move afterwards is refused. You can go back and you cannot come
|
||||
// forward, which is exactly the state there is no way out of from a
|
||||
// phone.
|
||||
//
|
||||
// What is given up is nothing, because git makes the same check
|
||||
// better: `git checkout` refuses when an untracked file would be
|
||||
// overwritten, and carries across the ones that would not. So this
|
||||
// guard covers uncommitted work somebody typed, and git's covers the
|
||||
// files it would clobber -- with git's refusal arriving as the error
|
||||
// below rather than as anything this has to predict.
|
||||
let dirty = git(project, &["status", "--porcelain", "--untracked-files=no"])
|
||||
.map(|out| !out.trim().is_empty())
|
||||
.unwrap_or(false);
|
||||
if dirty {
|
||||
return Err(format!(
|
||||
"{} has uncommitted changes -- refusing to move the checkout over them. Commit or \
|
||||
stash on the build machine first.",
|
||||
project.display()
|
||||
)
|
||||
.into());
|
||||
}
|
||||
// `--` so a branch and a path that share a name cannot be confused,
|
||||
// which is the one way a name from a phone could mean something other
|
||||
// than what it says.
|
||||
git(project, &["checkout", target, "--"]).map_err(PullError::from)?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Fast-forwards the current branch onto its upstream.
|
||||
///
|
||||
/// `--ff-only` deliberately: a merge or a rebase can conflict, and
|
||||
@@ -579,6 +788,142 @@ mod tests {
|
||||
);
|
||||
}
|
||||
|
||||
/// What the settings sheet's two pickers are built from, and the one
|
||||
/// thing about them that is easy to get wrong: `origin/main` must not
|
||||
/// be offered beside `main`, because picking it would detach HEAD
|
||||
/// from the branch somebody is already on while looking like a way
|
||||
/// back to it.
|
||||
#[test]
|
||||
fn the_pickers_offer_each_branch_once_and_mark_where_head_is() {
|
||||
let dir = tempfile::tempdir().expect("tempdir");
|
||||
let (origin, clone) = origin_and_clone(dir.path());
|
||||
// A branch that exists only on the remote, which is most of what
|
||||
// switching branches from a phone is for.
|
||||
run(&origin, &["git", "branch", "topic"]);
|
||||
run(&clone, &["git", "fetch", "-q"]);
|
||||
|
||||
let branches = branches(&clone);
|
||||
let named: Vec<&str> = branches.iter().map(|b| b.name.as_str()).collect();
|
||||
assert!(named.contains(&"main"), "{named:?}");
|
||||
assert!(
|
||||
!named.iter().any(|name| name.ends_with("/main")),
|
||||
"origin/main duplicates the local branch: {named:?}",
|
||||
);
|
||||
// `refs/remotes/origin/HEAD` abbreviates to a bare `origin`, so
|
||||
// this is what its leaking looks like -- not a name ending in
|
||||
// `/HEAD`, which is what an earlier version of this test looked
|
||||
// for and never found.
|
||||
assert!(
|
||||
!named.contains(&"origin"),
|
||||
"the remote's default symref must not be offered as a branch: {named:?}",
|
||||
);
|
||||
let topic = branches
|
||||
.iter()
|
||||
.find(|b| b.name == "origin/topic")
|
||||
.expect("a remote-only branch is offered");
|
||||
assert!(topic.remote_only);
|
||||
assert!(!topic.current);
|
||||
assert!(
|
||||
branches
|
||||
.iter()
|
||||
.find(|b| b.name == "main")
|
||||
.expect("main")
|
||||
.current,
|
||||
"the branch HEAD is on is the one marked current",
|
||||
);
|
||||
|
||||
let commits = recent_commits(&clone, 10);
|
||||
assert_eq!(commits.len(), 1);
|
||||
assert_eq!(commits[0].subject, "one");
|
||||
assert!(commits[0].current, "HEAD's own commit is marked");
|
||||
assert!(!commits[0].short.is_empty());
|
||||
}
|
||||
|
||||
/// The trap the tracked-only check exists for: build output left
|
||||
/// untracked by an older commit -- one from before the `.gitignore`
|
||||
/// that covers it -- must not make the way back impossible. Refusing
|
||||
/// on any dirt at all made this a one-way door, and the way out of it
|
||||
/// is the build machine, which is where the person holding the phone
|
||||
/// is not.
|
||||
#[test]
|
||||
fn untracked_build_output_does_not_trap_the_checkout() {
|
||||
let dir = tempfile::tempdir().expect("tempdir");
|
||||
let (_origin, clone) = origin_and_clone(dir.path());
|
||||
let first = recent_commits(&clone, 10)[0].sha.clone();
|
||||
std::fs::write(clone.join(".gitignore"), "out/\n").expect("write");
|
||||
run(&clone, &["git", "add", "."]);
|
||||
run(&clone, &["git", "commit", "-qm", "ignore out"]);
|
||||
|
||||
// Back to before the .gitignore, then "build" into the directory
|
||||
// it would have covered.
|
||||
checkout(&clone, &first).expect("moving back");
|
||||
std::fs::create_dir_all(clone.join("out")).expect("mkdir");
|
||||
std::fs::write(clone.join("out/app.apk"), "built").expect("write");
|
||||
assert!(
|
||||
status(&clone).expect("status").dirty,
|
||||
"untracked output really does make the tree dirty",
|
||||
);
|
||||
|
||||
checkout(&clone, "main").expect("and the way forward is still open");
|
||||
assert_eq!(status(&clone).expect("status").branch, "main");
|
||||
assert!(
|
||||
clone.join("out/app.apk").exists(),
|
||||
"git carries an untracked file across rather than discarding it",
|
||||
);
|
||||
}
|
||||
|
||||
/// Moving the checkout is refused over uncommitted work, and it
|
||||
/// matters more here than for a pull: a checkout can go *backwards*,
|
||||
/// so what it would clobber may exist nowhere else yet.
|
||||
#[test]
|
||||
fn a_checkout_refuses_a_dirty_tree_and_leaves_it_alone() {
|
||||
let dir = tempfile::tempdir().expect("tempdir");
|
||||
let (_origin, clone) = origin_and_clone(dir.path());
|
||||
std::fs::write(clone.join("file"), "two").expect("write");
|
||||
run(&clone, &["git", "commit", "-qam", "two"]);
|
||||
let first = recent_commits(&clone, 10)
|
||||
.last()
|
||||
.expect("the first commit")
|
||||
.sha
|
||||
.clone();
|
||||
|
||||
std::fs::write(clone.join("file"), "uncommitted").expect("write");
|
||||
let refused = checkout(&clone, &first).expect_err("a dirty tree is refused");
|
||||
assert!(
|
||||
refused.message.contains("uncommitted"),
|
||||
"{}",
|
||||
refused.message
|
||||
);
|
||||
assert_eq!(
|
||||
std::fs::read_to_string(clone.join("file")).expect("read"),
|
||||
"uncommitted",
|
||||
"the working tree is left exactly as it was",
|
||||
);
|
||||
|
||||
// Clean again, and the same move goes through and detaches HEAD --
|
||||
// which `status` reports as `HEAD` rather than inventing a branch.
|
||||
run(&clone, &["git", "checkout", "-q", "--", "file"]);
|
||||
checkout(&clone, &first).expect("a clean tree moves");
|
||||
assert_eq!(
|
||||
std::fs::read_to_string(clone.join("file")).expect("read"),
|
||||
"one",
|
||||
);
|
||||
assert_eq!(status(&clone).expect("status").branch, "HEAD");
|
||||
let detached = branches(&clone);
|
||||
assert!(
|
||||
detached.iter().all(|b| !b.current),
|
||||
"detached, so no branch is the current one",
|
||||
);
|
||||
// `git branch` adds a `(HEAD detached at ...)` line here, which is
|
||||
// not a branch and cannot be checked out. Asserted in the detached
|
||||
// case because that is the only state it appears in.
|
||||
assert!(
|
||||
detached.iter().all(|b| !b.name.starts_with('(')),
|
||||
"the detached-HEAD pseudo-entry is not a branch: {:?}",
|
||||
detached.iter().map(|b| &b.name).collect::<Vec<_>>(),
|
||||
);
|
||||
}
|
||||
|
||||
/// An origin repo with one commit, and a clone of it.
|
||||
fn origin_and_clone(root: &Path) -> (PathBuf, PathBuf) {
|
||||
let origin = root.join("origin");
|
||||
|
||||
@@ -13,6 +13,10 @@
|
||||
//! GET /apps/{key}/apk[?variant=] the payload itself (ranged)
|
||||
//! POST /apps/{key}/pull fetch, fast-forward, and build
|
||||
//! ?force=true resets onto the upstream
|
||||
//! GET /apps/{key}/refs its branches and recent commits, for
|
||||
//! the settings sheet's two pickers
|
||||
//! POST /apps/{key}/checkout {target}
|
||||
//! move the checkout there, then build
|
||||
//! POST /apps/{key}/prepare run the on-demand build step, if any
|
||||
//! ?component= restricts it to one
|
||||
//! POST /apps/{key}/build run it whether or not it looks stale
|
||||
@@ -95,6 +99,11 @@ pub fn tls_router(state: Arc<AppState>) -> Router {
|
||||
.route("/apps/{key}", get(app).delete(remove_app))
|
||||
.route("/apps/{key}/apk", get(serve_apk))
|
||||
.route("/apps/{key}/pull", post(build_pull))
|
||||
// Read when the settings sheet is opened, never on the manifest:
|
||||
// both of these spawn git, and the manifest is fetched on every
|
||||
// open, resume and Refresh.
|
||||
.route("/apps/{key}/refs", get(checkout_refs))
|
||||
.route("/apps/{key}/checkout", post(build_checkout))
|
||||
.route("/apps/{key}/prepare", post(build_prepare))
|
||||
.route("/apps/{key}/build", post(build_now))
|
||||
.route("/apps/{key}/approve", post(approve_declaration))
|
||||
@@ -1542,6 +1551,85 @@ async fn build_pull(
|
||||
Ok(Json(build.status()))
|
||||
}
|
||||
|
||||
#[derive(Serialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
struct RefsResponse {
|
||||
branches: Vec<crate::git::Branch>,
|
||||
commits: Vec<crate::git::CommitSummary>,
|
||||
}
|
||||
|
||||
/// How much history the commit picker offers.
|
||||
///
|
||||
/// Enough to reach back past a bad afternoon, and short enough that the
|
||||
/// list is still something a person scrolls rather than searches. Going
|
||||
/// further back is a job for the build machine, where the tools for
|
||||
/// finding a particular commit actually are.
|
||||
const COMMIT_HISTORY: usize = 50;
|
||||
|
||||
/// The branches and commits this project could be moved to.
|
||||
///
|
||||
/// Local reads only, so opening the sheet cannot stall on a network round
|
||||
/// trip or fail the way a remote check can. What the remote has and this
|
||||
/// checkout has not fetched is deliberately not here: Pull is what brings
|
||||
/// those in, and the card already says when there are some.
|
||||
async fn checkout_refs(
|
||||
State(state): State<Arc<AppState>>,
|
||||
key: UrlPath<String>,
|
||||
) -> Result<Json<RefsResponse>, ApiError> {
|
||||
let entry = lookup(&state, Some(key))?;
|
||||
let project = entry.project_path.clone();
|
||||
let refs = tokio::task::spawn_blocking(move || RefsResponse {
|
||||
branches: crate::git::branches(&project),
|
||||
commits: crate::git::recent_commits(&project, COMMIT_HISTORY),
|
||||
})
|
||||
.await
|
||||
.context("reading the checkout's refs panicked")?;
|
||||
Ok(Json(refs))
|
||||
}
|
||||
|
||||
#[derive(Deserialize)]
|
||||
struct CheckoutBody {
|
||||
/// A branch name or a commit hash, as the phone read it back from
|
||||
/// [`checkout_refs`]. Never checked against that list here: git is
|
||||
/// the thing that decides whether a ref exists, and a second opinion
|
||||
/// on this side could only ever disagree with it.
|
||||
target: String,
|
||||
}
|
||||
|
||||
/// Moves the checkout onto a branch or a commit, then builds what that
|
||||
/// left behind -- reported through the same status a pull is, because it
|
||||
/// is the same kind of act on the same single checkout.
|
||||
///
|
||||
/// Available while a declaration is waiting to be accepted, for the
|
||||
/// reason a pull is: moving the checkout runs git rather than the
|
||||
/// project's command, and it is how a different version of that request
|
||||
/// arrives to be read. What it must not do is *build* in that state,
|
||||
/// which is why the gate is asked afterwards, on the tree the move left.
|
||||
async fn build_checkout(
|
||||
State(state): State<Arc<AppState>>,
|
||||
key: UrlPath<String>,
|
||||
Json(body): Json<CheckoutBody>,
|
||||
) -> Result<Json<BuildStatus>, ApiError> {
|
||||
let entry = lookup(&state, Some(key))?;
|
||||
let target = body.target.trim().to_string();
|
||||
if target.is_empty() {
|
||||
return Err(ApiError::BadRequest(
|
||||
"say which branch or commit to move to".to_string(),
|
||||
));
|
||||
}
|
||||
let build = entry
|
||||
.build
|
||||
.as_ref()
|
||||
.ok_or_else(|| ApiError::NoBuildStep(entry.label.clone()))?;
|
||||
let gate = Arc::clone(&entry);
|
||||
build.checkout_and_build(
|
||||
target,
|
||||
move || gate.pending_declaration().is_none(),
|
||||
records_builds(&state, &entry.key),
|
||||
);
|
||||
Ok(Json(build.status()))
|
||||
}
|
||||
|
||||
async fn build_status(
|
||||
State(state): State<Arc<AppState>>,
|
||||
key: UrlPath<String>,
|
||||
|
||||
Reference in new issue
Block a user