Skip to content
174 changes: 174 additions & 0 deletions codex-rs/core/src/git_info.rs
Original file line number Diff line number Diff line change
Expand Up @@ -108,6 +108,61 @@ pub async fn collect_git_info(cwd: &Path) -> Option<GitInfo> {
Some(git_info)
}

/// A minimal commit summary entry used for pickers (subject + timestamp + sha).
#[derive(Clone, Debug, Serialize, Deserialize)]
pub struct CommitLogEntry {
pub sha: String,
/// Unix timestamp (seconds since epoch) of the commit time (committer time).
pub timestamp: i64,
/// Single-line subject of the commit message.
pub subject: String,
}

/// Return the last `limit` commits reachable from HEAD for the current branch.
/// Each entry contains the SHA, commit timestamp (seconds), and subject line.
/// Returns an empty vector if not in a git repo or on error/timeout.
pub async fn recent_commits(cwd: &Path, limit: usize) -> Vec<CommitLogEntry> {
// Ensure we're in a git repo first to avoid noisy errors.
let Some(out) = run_git_command_with_timeout(&["rev-parse", "--git-dir"], cwd).await else {
return Vec::new();
};
if !out.status.success() {
return Vec::new();
}

let fmt = "%H%x1f%ct%x1f%s"; // <sha> <US> <commit_time> <US> <subject>
let n = limit.max(1).to_string();
let Some(log_out) =
run_git_command_with_timeout(&["log", "-n", &n, &format!("--pretty=format:{fmt}")], cwd)
.await
else {
return Vec::new();
};
if !log_out.status.success() {
return Vec::new();
}

let text = String::from_utf8_lossy(&log_out.stdout);
let mut entries: Vec<CommitLogEntry> = Vec::new();
for line in text.lines() {
let mut parts = line.split('\u{001f}');
let sha = parts.next().unwrap_or("").trim();
let ts_s = parts.next().unwrap_or("").trim();
let subject = parts.next().unwrap_or("").trim();
if sha.is_empty() || ts_s.is_empty() {
continue;
}
let timestamp = ts_s.parse::<i64>().unwrap_or(0);
entries.push(CommitLogEntry {
sha: sha.to_string(),
timestamp,
subject: subject.to_string(),
});
}

entries
}

/// Returns the closest git sha to HEAD that is on a remote as well as the diff to that sha.
pub async fn git_diff_to_remote(cwd: &Path) -> Option<GitDiffToRemote> {
get_git_repo_root(cwd)?;
Expand Down Expand Up @@ -202,6 +257,11 @@ async fn get_default_branch(cwd: &Path) -> Option<String> {
}

// No remote-derived default; try common local defaults if they exist
get_default_branch_local(cwd).await
}

/// Attempt to determine the repository's default branch name from local branches.
async fn get_default_branch_local(cwd: &Path) -> Option<String> {
for candidate in ["main", "master"] {
if let Some(verify) = run_git_command_with_timeout(
&[
Expand Down Expand Up @@ -485,6 +545,46 @@ pub fn resolve_root_git_project_for_trust(cwd: &Path) -> Option<PathBuf> {
git_dir_path.parent().map(Path::to_path_buf)
}

/// Returns a list of local git branches.
/// Includes the default branch at the beginning of the list, if it exists.
pub async fn local_git_branches(cwd: &Path) -> Vec<String> {
let mut branches: Vec<String> = if let Some(out) =
run_git_command_with_timeout(&["branch", "--format=%(refname:short)"], cwd).await
&& out.status.success()
{
String::from_utf8_lossy(&out.stdout)
.lines()
.map(|s| s.trim().to_string())
.filter(|s| !s.is_empty())
.collect()
} else {
Vec::new()
};

branches.sort_unstable();

if let Some(base) = get_default_branch_local(cwd).await
&& let Some(pos) = branches.iter().position(|name| name == &base)
{
let base_branch = branches.remove(pos);
branches.insert(0, base_branch);
}

branches
}

/// Returns the current checked out branch name.
pub async fn current_branch_name(cwd: &Path) -> Option<String> {
let out = run_git_command_with_timeout(&["branch", "--show-current"], cwd).await?;
if !out.status.success() {
return None;
}
String::from_utf8(out.stdout)
.ok()
.map(|s| s.trim().to_string())
.filter(|name| !name.is_empty())
}

#[cfg(test)]
mod tests {
use super::*;
Expand Down Expand Up @@ -551,6 +651,80 @@ mod tests {
repo_path
}

#[tokio::test]
async fn test_recent_commits_non_git_directory_returns_empty() {
let temp_dir = TempDir::new().expect("Failed to create temp dir");
let entries = recent_commits(temp_dir.path(), 10).await;
assert!(entries.is_empty(), "expected no commits outside a git repo");
}

#[tokio::test]
async fn test_recent_commits_orders_and_limits() {
use tokio::time::Duration;
use tokio::time::sleep;

let temp_dir = TempDir::new().expect("Failed to create temp dir");
let repo_path = create_test_git_repo(&temp_dir).await;

// Make three distinct commits with small delays to ensure ordering by timestamp.
fs::write(repo_path.join("file.txt"), "one").unwrap();
Command::new("git")
.args(["add", "file.txt"])
.current_dir(&repo_path)
.output()
.await
.expect("git add");
Command::new("git")
.args(["commit", "-m", "first change"])
.current_dir(&repo_path)
.output()
.await
.expect("git commit 1");

sleep(Duration::from_millis(1100)).await;

fs::write(repo_path.join("file.txt"), "two").unwrap();
Command::new("git")
.args(["add", "file.txt"])
.current_dir(&repo_path)
.output()
.await
.expect("git add 2");
Command::new("git")
.args(["commit", "-m", "second change"])
.current_dir(&repo_path)
.output()
.await
.expect("git commit 2");

sleep(Duration::from_millis(1100)).await;

fs::write(repo_path.join("file.txt"), "three").unwrap();
Command::new("git")
.args(["add", "file.txt"])
.current_dir(&repo_path)
.output()
.await
.expect("git add 3");
Command::new("git")
.args(["commit", "-m", "third change"])
.current_dir(&repo_path)
.output()
.await
.expect("git commit 3");

// Request the latest 3 commits; should be our three changes in reverse time order.
let entries = recent_commits(&repo_path, 3).await;
assert_eq!(entries.len(), 3);
assert_eq!(entries[0].subject, "third change");
assert_eq!(entries[1].subject, "second change");
assert_eq!(entries[2].subject, "first change");
// Basic sanity on SHA formatting
for e in entries {
assert!(e.sha.len() >= 7 && e.sha.chars().all(|c| c.is_ascii_hexdigit()));
}
}

async fn create_test_git_repo_with_remote(temp_dir: &TempDir) -> (PathBuf, String) {
let repo_path = create_test_git_repo(temp_dir).await;
let remote_path = temp_dir.path().join("remote.git");
Expand Down
12 changes: 12 additions & 0 deletions codex-rs/tui/src/app.rs
Original file line number Diff line number Diff line change
Expand Up @@ -355,6 +355,18 @@ impl App {
AppEvent::UpdateSandboxPolicy(policy) => {
self.chat_widget.set_sandbox_policy(policy);
}
AppEvent::OpenReviewBranchPicker(cwd) => {
self.chat_widget.show_review_branch_picker(&cwd).await;
}
AppEvent::OpenReviewCommitPicker(cwd) => {
self.chat_widget.show_review_commit_picker(&cwd).await;
}
AppEvent::OpenReviewCustomPrompt => {
self.chat_widget.show_review_custom_prompt();
}
AppEvent::OpenReviewPopup => {
self.chat_widget.open_review_popup();
}
}
Ok(true)
}
Expand Down
14 changes: 14 additions & 0 deletions codex-rs/tui/src/app_event.rs
Original file line number Diff line number Diff line change
@@ -1,3 +1,5 @@
use std::path::PathBuf;

use codex_core::protocol::ConversationPathResponseEvent;
use codex_core::protocol::Event;
use codex_file_search::FileMatch;
Expand Down Expand Up @@ -65,4 +67,16 @@ pub(crate) enum AppEvent {

/// Forwarded conversation history snapshot from the current conversation.
ConversationHistory(ConversationPathResponseEvent),

/// Open the branch picker option from the review popup.
OpenReviewBranchPicker(PathBuf),

/// Open the commit picker option from the review popup.
OpenReviewCommitPicker(PathBuf),

/// Open the custom prompt option from the review popup.
OpenReviewCustomPrompt,

/// Open the top-level review presets popup.
OpenReviewPopup,
}
11 changes: 11 additions & 0 deletions codex-rs/tui/src/bottom_pane/bottom_pane_view.rs
Original file line number Diff line number Diff line change
Expand Up @@ -28,6 +28,17 @@ pub(crate) trait BottomPaneView {
/// Render the view: this will be displayed in place of the composer.
fn render(&self, area: Rect, buf: &mut Buffer);

/// Optional paste handler. Return true if the view modified its state and
/// needs a redraw.
fn handle_paste(&mut self, _pane: &mut BottomPane, _pasted: String) -> bool {
false
}

/// Cursor position when this view is active.
fn cursor_pos(&self, _area: Rect) -> Option<(u16, u16)> {
None
}

/// Try to handle approval request; return the original value if not
/// consumed.
fn try_consume_approval_request(
Expand Down
Loading
Loading