Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
40 changes: 35 additions & 5 deletions crates/okena-daemon-core/src/pty_loop.rs
Original file line number Diff line number Diff line change
Expand Up @@ -1137,6 +1137,32 @@ mod tests {
(repo, worktree)
}

/// A shell for a hook PTY a test waits on.
///
/// Production hooks go through `ShellType::for_command`, which runs
/// `$SHELL -ic` so the hook sees the user's aliases and environment. In a
/// test that only buys a dependency on what the developer's interactive
/// profile costs to start: an interactive zsh that takes 1.6s to reach the
/// command runs these waits out of budget on a machine where nothing is
/// wrong. What is under test is PTY exit handling, not shell resolution, so
/// run the command in a bare shell.
/// How long a test waits for a hook PTY to report its exit.
const HOOK_EXIT_BUDGET: Duration = Duration::from_secs(20);

fn hook_shell(command: &str) -> ShellType {
if cfg!(windows) {
ShellType::Custom {
path: "cmd".to_string(),
args: vec!["/C".to_string(), command.to_string()],
}
} else {
ShellType::Custom {
path: "/bin/sh".to_string(),
args: vec!["-c".to_string(), command.to_string()],
}
}
}

fn workspace_with_pending_close(
main_repo: &Path,
worktree: &Path,
Expand Down Expand Up @@ -1744,7 +1770,7 @@ mod tests {
let hook_terminal_id = pty_manager
.create_terminal_with_shell(
worktree.to_str().expect("utf-8 worktree path"),
Some(&ShellType::for_command("exit 0".to_string())),
Some(&hook_shell("exit 0")),
)
.expect("create before-remove hook PTY");
let pty_manager = Arc::new(pty_manager);
Expand Down Expand Up @@ -1782,7 +1808,9 @@ mod tests {
let mut exit_events = Vec::new();
let mut dirty_terminal_ids = Vec::new();
let mut budget = TurnBudget::default();
tokio::time::timeout(Duration::from_secs(2), async {
// Generous on purpose: this budget is here to turn a hang
// into a failure, not to assert how fast a PTY starts.
tokio::time::timeout(HOOK_EXIT_BUDGET, async {
while exit_events.is_empty() {
let event = pty_events.recv().await.expect("receive hook PTY event");
process_event(
Expand Down Expand Up @@ -1868,7 +1896,7 @@ mod tests {
let hook_terminal_id = pty_manager
.create_terminal_with_shell(
worktree.to_str().expect("utf-8 worktree path"),
Some(&ShellType::for_command("exit 0".to_string())),
Some(&hook_shell("exit 0")),
)
.expect("create before-remove hook PTY");
let pty_manager = Arc::new(pty_manager);
Expand Down Expand Up @@ -1907,7 +1935,9 @@ mod tests {
let mut exit_events = Vec::new();
let mut dirty_terminal_ids = Vec::new();
let mut budget = TurnBudget::default();
tokio::time::timeout(Duration::from_secs(2), async {
// Generous on purpose: this budget is here to turn a hang
// into a failure, not to assert how fast a PTY starts.
tokio::time::timeout(HOOK_EXIT_BUDGET, async {
while exit_events.is_empty() {
let event = pty_events.recv().await.expect("receive hook PTY event");
process_event(
Expand Down Expand Up @@ -1978,7 +2008,7 @@ mod tests {
let hook_terminal_id = pty_manager
.create_terminal_with_shell(
worktree.to_str().expect("utf-8 worktree path"),
Some(&ShellType::for_command("sleep 30".to_string())),
Some(&hook_shell("sleep 30")),
)
.expect("create keep-alive before-remove hook PTY");
let pty_manager = Arc::new(pty_manager);
Expand Down
21 changes: 19 additions & 2 deletions crates/okena-git/src/error.rs
Original file line number Diff line number Diff line change
Expand Up @@ -19,8 +19,11 @@ pub enum GitError {
#[error("directory '{path}' is already an active worktree")]
WorktreeExists { path: PathBuf },

/// Failed to remove a directory.
#[error("failed to remove directory '{path}'")]
/// Failed to remove a directory. The cause belongs in the message: this
/// error reaches the user as a toast and the log as `{e}`, and neither
/// walks the source chain, so without it a failed worktree close reports
/// only the path it already named.
#[error("failed to remove directory '{path}': {source}")]
RemoveFailed {
path: PathBuf,
#[source]
Expand Down Expand Up @@ -100,6 +103,20 @@ mod tests {
);
}

/// The io cause is what says whether the close failed on a busy directory,
/// a permission, or a vanished path. Dropping it leaves the user with a
/// sentence that only repeats the path.
#[test]
fn a_failed_removal_names_its_cause() {
let err = GitError::RemoveFailed {
path: PathBuf::from("/tmp/wt"),
source: std::io::Error::from(std::io::ErrorKind::DirectoryNotEmpty),
};
let message = err.user_detail();
assert!(message.contains("/tmp/wt"), "{message}");
assert!(message.contains("not empty"), "{message}");
}

#[test]
fn the_last_failure_line_wins() {
let err = GitError::GitExitError {
Expand Down
93 changes: 86 additions & 7 deletions crates/okena-git/src/repository/ci.rs
Original file line number Diff line number Diff line change
Expand Up @@ -13,7 +13,7 @@ use okena_core::process::{command, safe_output_with_timeout};
use serde_json::{Value, json};

use super::github::{ApiError, GithubClient, GithubRepo, resolve_base_repo};
use super::status::get_pushed_sha;
use super::status::get_upstream_ref;

/// Hard cap on the remaining `gh` invocation. `gh` can hang indefinitely —
/// auth prompts or a stalled network — and the bus kills the process when
Expand Down Expand Up @@ -135,6 +135,33 @@ struct PrNode {
head_ref_oid: Option<String>,
}

/// The pushed commit that belongs to *this* branch, or `None` when the branch
/// tracks somebody else's.
///
/// Git records an upstream whenever a branch starts from a remote-tracking ref,
/// and a worktree branch starts from `origin/<default>`. Until it is pushed,
/// its upstream is therefore the default branch, and a lookup keyed on that
/// commit answers with the default branch's CI: a nightly deploy reported as a
/// failure against a worktree that never triggered it, and a PR head compared
/// against a commit from another branch.
///
/// A branch that deliberately tracks a differently named remote branch still
/// gets its own answer. Only the default branch is treated as a base rather
/// than a counterpart, which is the case Okena creates itself.
fn branch_pushed_sha(path: &Path) -> Option<String> {
let upstream = get_upstream_ref(path)?;
let branch = super::status::get_current_branch(path)?;
if upstream.branch == branch {
return Some(upstream.sha);
}
// Only reached for the mismatch, so the default-branch lookup stays off the
// path every well-tracked branch takes.
match super::branch::get_default_branch(path) {
Some(default) if default == upstream.branch => None,
_ => Some(upstream.sha),
}
}

/// Get PR info for the current branch (if any PR exists).
///
/// Matches by head branch name in the base repository, like `gh pr list
Expand All @@ -146,7 +173,7 @@ pub fn fetch_pr_info(path: &Path) -> PrFetch {
return PrFetch::Fetched(None);
};
let current_sha = super::status::get_head_sha(path);
let pushed_sha = get_pushed_sha(path);
let pushed_sha = branch_pushed_sha(path);
let Some((mut client, repo)) = github_client(path) else {
return PrFetch::Fetched(None);
};
Expand Down Expand Up @@ -340,9 +367,9 @@ fn rollup_status(failed: usize, pending: usize) -> crate::CiStatus {
///
/// With a known PR number, reads the PR's status-check rollup (Actions +
/// external status checks aggregated by the PR, as `gh pr checks` does).
/// Otherwise falls back to `check-runs` + `status` on the current upstream
/// commit, which works for any pushed branch — including default branches
/// without a PR.
/// Otherwise falls back to `check-runs` + `status` on the branch's own pushed
/// commit (see `branch_pushed_sha`), which works for any pushed branch,
/// default branches without a PR included.
///
/// `unchanged_sha` is the upstream commit a *settled* cached summary describes.
/// Checks on a given commit only move while something is running, so when the
Expand All @@ -358,7 +385,7 @@ pub fn fetch_ci_checks(
unchanged_sha: Option<&str>,
) -> CiFetch {
// Read locally (gix, no network) before deciding to spend a request.
let sha = get_pushed_sha(path);
let sha = branch_pushed_sha(path);
if let (Some(sha), Some(cached)) = (sha.as_deref(), unchanged_sha)
&& sha == cached
{
Expand Down Expand Up @@ -1247,13 +1274,65 @@ mod tests {
);
super::super::test_support::git_in(&repo, &["push", "-u", "origin", "main"]);

let sha = super::get_pushed_sha(&repo).expect("branch has an upstream");
let sha = super::super::status::get_pushed_sha(&repo).expect("branch has an upstream");
assert_eq!(
super::fetch_ci_checks(&repo, None, Some(&sha)),
super::CiFetch::Unchanged
);
}

/// The exact shape a worktree branch had before `--no-track`: it tracks the
/// default branch, so its "upstream commit" is main's tip. A lookup keyed
/// on that commit answers with main's CI, which is how a nightly deploy
/// failure ended up reported against a feature worktree.
#[test]
fn a_branch_tracking_the_default_branch_is_not_asked_about() {
let (_tmp, repo, _remote) = super::super::test_support::repo_with_origin();
super::super::test_support::git_in(&repo, &["checkout", "-q", "-b", "feat/x"]);
// What `git worktree add -b feat/x <path> origin/main` used to record.
super::super::test_support::git_in(&repo, &["config", "branch.feat/x.remote", "origin"]);
super::super::test_support::git_in(
&repo,
&["config", "branch.feat/x.merge", "refs/heads/main"],
);

assert!(
super::branch_pushed_sha(&repo).is_none(),
"main's tip is not this branch's pushed commit"
);
// And no request is spent finding that out.
assert_eq!(
super::fetch_ci_checks(&repo, None, None),
super::CiFetch::Fetched {
sha: None,
summary: None
}
);
}

/// A branch that tracks its own counterpart still gets its own answer, and
/// so does one deliberately tracking a differently named remote branch.
#[test]
fn a_branch_tracking_its_own_remote_keeps_its_commit() {
let (_tmp, repo, _remote) = super::super::test_support::repo_with_origin();
super::super::test_support::git_in(&repo, &["checkout", "-q", "-b", "feat/x"]);
super::super::test_support::git_in(&repo, &["push", "-q", "-u", "origin", "feat/x"]);
let own = super::branch_pushed_sha(&repo).expect("its own upstream counts");

// Now point it at a non-default remote branch under another name.
super::super::test_support::git_in(&repo, &["push", "-q", "origin", "feat/x:other"]);
super::super::test_support::git_in(&repo, &["fetch", "-q", "origin"]);
super::super::test_support::git_in(
&repo,
&["config", "branch.feat/x.merge", "refs/heads/other"],
);
assert_eq!(
super::branch_pushed_sha(&repo).as_deref(),
Some(own.as_str()),
"tracking another branch on purpose is still this branch's answer"
);
}

#[test]
fn branch_without_upstream_never_reaches_the_api() {
// Nothing is pushed, so there is nothing CI could have run on.
Expand Down
12 changes: 12 additions & 0 deletions crates/okena-git/src/repository/mod.rs
Original file line number Diff line number Diff line change
Expand Up @@ -166,6 +166,18 @@ pub(crate) mod test_support {
String::from_utf8_lossy(&status.stderr)
);
}
/// A repo whose `main` is pushed to a bare `origin`. The second tempdir owns
/// the remote and must stay alive for the repo's lifetime.
pub(crate) fn repo_with_origin() -> (tempfile::TempDir, PathBuf, tempfile::TempDir) {
let (tmp, repo) = init_temp_repo();
let remote_tmp = tempfile::tempdir().expect("create remote tempdir");
let remote = remote_tmp.path().join("remote.git");
let remote_str = remote.to_str().expect("remote path is utf-8");
git_in(&repo, &["init", "--bare", "-b", "main", remote_str]);
git_in(&repo, &["remote", "add", "origin", remote_str]);
git_in(&repo, &["push", "-q", "origin", "main"]);
(tmp, repo, remote_tmp)
}
}

#[cfg(test)]
Expand Down
62 changes: 54 additions & 8 deletions crates/okena-git/src/repository/status.rs
Original file line number Diff line number Diff line change
Expand Up @@ -197,14 +197,23 @@ pub fn get_head_sha(path: &Path) -> Option<String> {
Some(id.to_hex().to_string())
}

/// Full SHA of the current branch's upstream tracking commit — the last commit
/// known (from the latest fetch) to be on the remote. `None` if HEAD is
/// detached or the branch has no upstream (never pushed).
/// The remote branch the current branch tracks, and that branch's commit.
///
/// Branch-level CI lookups (`/commits/{sha}/check-runs` and `/status`) must use
/// this rather than the local HEAD: GitHub runs CI against *pushed* commits, so
/// querying an unpushed local HEAD just returns nothing.
pub fn get_pushed_sha(path: &Path) -> Option<String> {
/// The name matters as much as the commit: a branch does not necessarily track
/// its own counterpart. Git records an upstream whenever a branch is started
/// from a remote-tracking ref, so a branch created from `origin/main` tracks
/// `main` until it is pushed, and its "upstream commit" is the default
/// branch's tip rather than anything this branch did.
pub struct UpstreamRef {
/// Branch name on the remote, without the remote prefix: `main`, `feat/x`.
pub branch: String,
/// Full SHA that remote branch points at, as of the latest fetch.
pub sha: String,
}

/// The current branch's upstream. `None` if HEAD is detached or the branch has
/// no upstream (never pushed, and not started from a remote-tracking ref).
pub fn get_upstream_ref(path: &Path) -> Option<UpstreamRef> {
let repo = crate::gix_helpers::open(path)?;
let branch = super::head_branch_short(&repo)?;
let head_ref = repo
Expand All @@ -218,7 +227,29 @@ pub fn get_pushed_sha(path: &Path) -> Option<String> {
.rev_parse_single(upstream_name.as_bstr())
.ok()?
.detach();
Some(id.to_hex().to_string())
Some(UpstreamRef {
branch: remote_branch_name(&upstream_name.as_bstr().to_string())?,
sha: id.to_hex().to_string(),
})
}

/// `refs/remotes/origin/feat/x` -> `feat/x`. The remote is one segment, the
/// branch is everything after it, slashes included.
fn remote_branch_name(full_ref: &str) -> Option<String> {
let (_remote, branch) = full_ref.strip_prefix("refs/remotes/")?.split_once('/')?;
(!branch.is_empty()).then(|| branch.to_string())
}

/// Full SHA of the current branch's upstream tracking commit: the last commit
/// known (from the latest fetch) to be on the remote. `None` if HEAD is
/// detached or the branch has no upstream (never pushed).
///
/// Branch-level CI lookups (`/commits/{sha}/check-runs` and `/status`) must use
/// this rather than the local HEAD: GitHub runs CI against *pushed* commits, so
/// querying an unpushed local HEAD just returns nothing. They must also check
/// *whose* upstream it is (see `UpstreamRef`).
pub fn get_pushed_sha(path: &Path) -> Option<String> {
get_upstream_ref(path).map(|upstream| upstream.sha)
}

/// Tracked per-file diff counts and the untracked-file list, produced by a
Expand Down Expand Up @@ -665,6 +696,21 @@ mod tests {
assert!(get_current_branch(&path).is_none());
}

#[test]
fn a_remote_ref_splits_into_remote_and_branch() {
assert_eq!(
remote_branch_name("refs/remotes/origin/main").as_deref(),
Some("main")
);
// A slash in the branch name belongs to the branch, not the remote.
assert_eq!(
remote_branch_name("refs/remotes/origin/feat/x").as_deref(),
Some("feat/x")
);
assert_eq!(remote_branch_name("refs/heads/main"), None);
assert_eq!(remote_branch_name("refs/remotes/origin/"), None);
}

#[test]
fn count_unpushed_commits_returns_none_for_invalid_path() {
let path = PathBuf::from("/nonexistent/path/that/does/not/exist");
Expand Down
Loading
Loading