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
2 changes: 1 addition & 1 deletion Cargo.lock

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

36 changes: 26 additions & 10 deletions src/ai.rs
Original file line number Diff line number Diff line change
Expand Up @@ -86,21 +86,37 @@ pub fn run_capturing(
.spawn()
.map_err(|e| AiError::Spawn(format!("Failed to spawn {command}: {e}")))?;

if let Some(input) = stdin {
// Take the handle and drop it after writing so the agent sees EOF.
let mut handle = child
.stdin
.take()
.ok_or_else(|| AiError::Failed(format!("{command}: failed to open stdin")))?;
handle
.write_all(input.as_bytes())
.map_err(|e| AiError::Io(format!("I/O error talking to {command}: {e}")))?;
}
// Feed stdin from a separate thread so a large prompt can't deadlock: the
// agent may emit stdout/stderr while we're still writing, and once those
// ~64KB pipe buffers fill it blocks on write. `wait_with_output` below drains
// both output pipes concurrently, so writing stdin off-thread keeps both
// sides flowing. The handle drops when the thread ends, closing stdin so the
// agent sees EOF.
let writer = match stdin {
Some(input) => {
let mut handle = child
.stdin
.take()
.ok_or_else(|| AiError::Failed(format!("{command}: failed to open stdin")))?;
let data = input.as_bytes().to_vec();
// Write errors (e.g. the agent closed stdin early) are intentionally
// ignored here; the exit status and captured stderr below reflect the
// real outcome.
Some(std::thread::spawn(move || {
let _ = handle.write_all(&data);
}))
}
None => None,
};

let output = child
.wait_with_output()
.map_err(|e| AiError::Io(format!("I/O error talking to {command}: {e}")))?;

if let Some(writer) = writer {
let _ = writer.join();
}

if !output.status.success() {
let stderr = String::from_utf8_lossy(&output.stderr).trim().to_string();
let detail = if stderr.is_empty() {
Expand Down
10 changes: 9 additions & 1 deletion src/commands/commit.rs
Original file line number Diff line number Diff line change
Expand Up @@ -98,7 +98,15 @@ pub fn run(message: Option<String>, amend: bool, no_edit: bool, ai: bool) -> Res
}

fn run_ai_commit(amend: bool) -> Result<()> {
let diff = git::staging::get_staged_diff().map_err(CommitError::GitError)?;
// On --amend the staging step is skipped, so the index matches HEAD and the
// staged diff would be empty; diff against HEAD's parent instead so the AI
// sees the content of the commit being amended.
let diff = if amend {
git::staging::get_amend_diff()
} else {
git::staging::get_staged_diff()
}
.map_err(CommitError::GitError)?;

if diff.is_empty() {
return Err(CommitError::NothingToCommit.into());
Expand Down
11 changes: 10 additions & 1 deletion src/commands/pr.rs
Original file line number Diff line number Diff line change
Expand Up @@ -171,8 +171,17 @@ will be treated as untrusted input.",
let prompt = build_investigate_prompt(pr);

eprintln!("Launching {agent} in {}…", path.display());
ai::launch_interactive(&agent, &cfg.ai.model, &prompt, &path)
let status = ai::launch_interactive(&agent, &cfg.ai.model, &prompt, &path)
.map_err(|e| PrCommandError::Ai(e.to_string()))?;
// A non-zero exit (crash, auth failure) is a real signal; surface it instead
// of reporting silent success. It isn't fatal to gx — the workspace is already
// prepared — so warn rather than return an error.
if !status.success() {
match status.code() {
Some(code) => eprintln!("Warning: {agent} exited with status {code}"),
None => eprintln!("Warning: {agent} was terminated by a signal"),
}
}
Ok(())
}

Expand Down
6 changes: 5 additions & 1 deletion src/commands/setup.rs
Original file line number Diff line number Diff line change
Expand Up @@ -34,7 +34,11 @@ pub fn run() -> Result<()> {
output.push_str(&format!("# Config file: {}\n", config_path.display()));
output.push_str("# Run: eval \"$(gx setup)\"\n\n");

for (alias, command) in &config.aliases {
// Sort by alias name so `gx setup` emits a stable order (config.aliases is a
// HashMap, whose iteration order varies run to run).
let mut aliases: Vec<(&String, &String)> = config.aliases.iter().collect();
aliases.sort_by(|(a, _), (b, _)| a.cmp(b));
for (alias, command) in aliases {
output.push_str(&format!("alias {}='gx {}'\n", alias, command));
}

Expand Down
3 changes: 3 additions & 0 deletions src/git/branch.rs
Original file line number Diff line number Diff line change
Expand Up @@ -172,6 +172,9 @@ pub fn get_recent_commits(branch_name: &str, limit: usize) -> Result<Vec<String>
let commit = resolve_branch_commit(&repo, branch_name)?;

let mut revwalk = repo.revwalk()?;
// Without an explicit sort the walk order is unspecified, so `take(limit)`
// could miss the actual most-recent commits; TIME order yields newest first.
revwalk.set_sorting(git2::Sort::TIME)?;
revwalk.push(commit.id())?;

let messages: Vec<String> = revwalk
Expand Down
30 changes: 23 additions & 7 deletions src/git/staging.rs
Original file line number Diff line number Diff line change
Expand Up @@ -86,15 +86,31 @@ pub fn stage_all() -> Result<Vec<String>, GitError> {

pub fn get_staged_diff() -> Result<String, GitError> {
let repo = get_repo()?;
let mut diff_options = git2::DiffOptions::new();

let head_tree = repo.head().ok().and_then(|head| head.peel_to_tree().ok());
diff_index_against(&repo, head_tree.as_ref())
}

let diff = repo.diff_tree_to_index(
head_tree.as_ref(),
Some(&repo.index()?),
Some(&mut diff_options),
)?;
/// Diff for the commit produced by `--amend`: the index against HEAD's *parent*,
/// so it reflects the full content of the amended commit (the original change
/// plus anything newly staged). `get_staged_diff` would compare against HEAD and
/// therefore be empty on a plain reword, which is why amend needs its own diff.
pub fn get_amend_diff() -> Result<String, GitError> {
let repo = get_repo()?;
let head_commit = repo.head()?.peel_to_commit()?;
let parent_tree = match head_commit.parent(0) {
Ok(parent) => Some(parent.tree()?),
// Amending the root commit: diff against the empty tree.
Err(_) => None,
};
diff_index_against(&repo, parent_tree.as_ref())
}

fn diff_index_against(
repo: &git2::Repository,
old_tree: Option<&git2::Tree>,
) -> Result<String, GitError> {
let mut diff_options = git2::DiffOptions::new();
let diff = repo.diff_tree_to_index(old_tree, Some(&repo.index()?), Some(&mut diff_options))?;

let mut diff_text = String::new();
diff.print(git2::DiffFormat::Patch, |_delta, _hunk, line| {
Expand Down
52 changes: 49 additions & 3 deletions src/git/stash.rs
Original file line number Diff line number Diff line change
Expand Up @@ -163,9 +163,19 @@ pub fn branch(name: &str, index: usize) -> Result<(), GitError> {
}

fn extract_branch_from_message(message: &str) -> String {
let lower = message.to_lowercase();
if let Some(start) = lower.find("on ") {
let rest = &message[start + 3..];
// Stash messages look like "WIP on <branch>: ..." or "On <branch>: ...".
// Locate "on " case-insensitively via byte windows on the ORIGINAL string:
// lowercasing first can change byte lengths for non-ASCII text, so an index
// from the lowercased string can land mid-char and either return the wrong
// slice or panic. The needle is ASCII, so the matched offset is a valid
// char boundary in the original.
let needle = b"on ";
if let Some(start) = message
.as_bytes()
.windows(needle.len())
.position(|window| window.eq_ignore_ascii_case(needle))
{
let rest = &message[start + needle.len()..];
if let Some(end) = rest.find(':') {
return rest[..end].to_string();
}
Expand All @@ -182,3 +192,39 @@ fn extract_stash_description(message: &str) -> String {
}
message.to_string()
}

#[cfg(test)]
mod tests {
use super::*;

#[test]
fn test_extract_branch_from_message_standard_formats() {
assert_eq!(
extract_branch_from_message("WIP on main: 1a2b3c msg"),
"main"
);
assert_eq!(
extract_branch_from_message("On feature/x: 1a2b3c msg"),
"feature/x"
);
}

#[test]
fn test_extract_branch_from_message_non_ascii_does_not_panic() {
// A multibyte char before "on " used to shift the lowercased byte index
// and could slice mid-char (panic) or return a wrong substring.
let msg = "WIP on naïve-café: 1a2b3c work";
assert_eq!(extract_branch_from_message(msg), "naïve-café");

let msg = "On 日本語-branch: deadbee did things";
assert_eq!(extract_branch_from_message(msg), "日本語-branch");
}

#[test]
fn test_extract_branch_from_message_unknown_when_no_match() {
assert_eq!(
extract_branch_from_message("garbage without marker"),
"unknown"
);
}
}
6 changes: 4 additions & 2 deletions src/git/time.rs
Original file line number Diff line number Diff line change
@@ -1,10 +1,12 @@
use std::time::{SystemTime, UNIX_EPOCH};

pub fn now_secs() -> i64 {
// A clock set before 1970 would make `duration_since` error; fall back to the
// epoch rather than panicking (relative times just read as very old).
SystemTime::now()
.duration_since(UNIX_EPOCH)
.unwrap()
.as_secs() as i64
.map(|d| d.as_secs() as i64)
.unwrap_or(0)
}

pub fn format_relative(diff_secs: i64) -> String {
Expand Down
7 changes: 7 additions & 0 deletions src/git/worktree.rs
Original file line number Diff line number Diff line change
Expand Up @@ -149,11 +149,15 @@ pub fn add(
}
args.push("-b".to_string());
args.push(branch.to_string());
// End option parsing so a path or base beginning with '-' is never read
// as a flag.
args.push("--".to_string());
args.push(path.display().to_string());
if let Some(base) = base {
args.push(base.to_string());
}
} else {
args.push("--".to_string());
args.push(path.display().to_string());
args.push(branch.to_string());
}
Expand All @@ -177,6 +181,7 @@ pub fn rebase_onto(path: &Path, base: &str) -> Result<(), GitError> {
"-C".to_string(),
path.display().to_string(),
"rebase".to_string(),
"--".to_string(),
base.to_string(),
],
ExecOptions {
Expand Down Expand Up @@ -219,6 +224,7 @@ pub fn remove(from: &Path, path: &Path, force: bool) -> Result<(), GitError> {
if force {
args.push("--force".to_string());
}
args.push("--".to_string());
args.push(path.display().to_string());

git_exec::exec(
Expand All @@ -240,6 +246,7 @@ pub fn delete_branch(from: &Path, branch_name: &str, force: bool) -> Result<(),
from.display().to_string(),
"branch".to_string(),
delete_flag.to_string(),
"--".to_string(),
branch_name.to_string(),
],
ExecOptions {
Expand Down
Loading
Loading