Skip to content
Open
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
1 change: 1 addition & 0 deletions src-tauri/src/lib.rs
Original file line number Diff line number Diff line change
Expand Up @@ -231,6 +231,7 @@ pub fn run() {
git::commands::git_unstage,
git::commands::git_discard,
git::commands::git_commit,
git::commands::git_undo_commit,
git::commands::git_fetch,
git::commands::git_pull_ff_only,
git::commands::git_push,
Expand Down
15 changes: 15 additions & 0 deletions src-tauri/src/modules/git/commands.rs
Original file line number Diff line number Diff line change
Expand Up @@ -155,6 +155,21 @@ pub async fn git_commit(
.await
}

#[tauri::command]
pub async fn git_undo_commit(
repo_root: String,
expected_head_sha: String,
workspace: Option<WorkspaceEnv>,
app: AppHandle,
) -> Result<(), String> {
let workspace = WorkspaceEnv::from_option(workspace);
blocking(app, move |r| {
operations::undo_last_commit(r, &repo_root, &expected_head_sha, &workspace)
.map_err(Into::into)
})
.await
}

#[tauri::command]
pub async fn git_fetch(
repo_root: String,
Expand Down
57 changes: 57 additions & 0 deletions src-tauri/src/modules/git/operations.rs
Original file line number Diff line number Diff line change
Expand Up @@ -436,6 +436,63 @@ pub fn commit(
})
}

pub fn undo_last_commit(
registry: &WorkspaceRegistry,
repo_root: &str,
expected_head_sha: &str,
workspace: &WorkspaceEnv,
) -> Result<()> {
let repo_root = authorized_repo_root(registry, repo_root, workspace)?;
ensure_git_available(&repo_root.workspace)?;

if expected_head_sha.len() != 40
|| !expected_head_sha.bytes().all(|b| b.is_ascii_hexdigit())
{
return Err(GitError::command(
"git update-ref",
"expected a full commit sha",
));
}
Comment on lines +448 to +455

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🎯 Functional Correctness | 🟡 Minor | ⚡ Quick win

🧩 Analysis chain

🌐 Web query:

git SHA-256 object format commit hash length hex characters

💡 Result:

In Git, when a repository is configured to use the SHA-256 hash function (enabled via a repository format extension), object identifiers (commit hashes and other object names) are represented as 64 hexadecimal characters [1][2]. This is an increase from the 40 hexadecimal characters used for SHA-1 object identifiers [1][2]. Key technical details regarding this format include: Hash Representation: Objects are identified by their SHA-256 hash, which results in a 64-character hexadecimal string [1][2]. Object Content and References: In a SHA-256 repository, not only are the object names (hashes) different, but the internal content of objects (such as commit, tree, and tag objects) is also updated to reference other objects by their SHA-256 names instead of SHA-1 names [2][3]. Compatibility: While SHA-256 repositories use 64-character hashes, Git provides mechanisms to facilitate interoperability. For instance, repositories can maintain a bidirectional mapping between SHA-1 and SHA-256 object names, allowing users to reference objects using either format interchangeably in some contexts, and enabling communication with SHA-1 based Git servers [3][4]. Format Identification: Git internals, such as packfiles and multi-pack-index files, use a specific hash function identifier (e.g., 2 for SHA-256, 1 for SHA-1) to determine the length and type of object IDs being processed [5].

Citations:


🏁 Script executed:

sed -n '400,520p' src-tauri/src/modules/git/operations.rs

rg -n "object-format|sha256|expected_head_sha|is_ascii_hexdigit|len\(\) != 40|64" src-tauri/src/modules/git -S

Repository: crynta/terax-ai

Length of output: 6308


Accept 64-char object IDs here too

undo_last_commit rejects any full SHA unless it is 40 hex chars, which breaks SHA-256 repos where git rev-parse and git update-ref use 64-char object IDs. This guard should accept both lengths, or reuse the existing SHA helper here.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@src-tauri/src/modules/git/operations.rs` around lines 448 - 455, The SHA
validation in undo_last_commit is too strict and only accepts 40-character hex
IDs, which breaks SHA-256 repositories. Update the expected_head_sha check in
operations.rs to accept both full 40-char and 64-char hexadecimal object IDs, or
replace the inline validation with the existing SHA helper used elsewhere in git
operations. Keep the same error path in GitError::command, but ensure valid full
object IDs of either length pass through to the git update-ref flow.


// Derive the parent from the expected sha, not from HEAD, so a racing
// commit cannot redirect which commit gets undone.
let parent = git_stdout_line_opt(
&repo_root.workspace,
&repo_root.git_path,
[
"rev-parse",
"--verify",
"--quiet",
&format!("{expected_head_sha}^"),
],
)?
.ok_or_else(|| GitError::command("git update-ref", "cannot undo the initial commit"))?;

// A soft reset is just a ref move; update-ref with an old-value check makes
// it an atomic compare-and-swap, so HEAD moving after the commit list was
// loaded fails the update instead of discarding the wrong commit.
let output = run_git(
&repo_root.workspace,
Some(&repo_root.git_path),
[
"update-ref",
"-m",
"reset: moving to HEAD~1 (undo last commit)",
"HEAD",
&parent,
expected_head_sha,
],
DEFAULT_TIMEOUT_SECS,
)?;
if output.exit_code != Some(0) {
return Err(GitError::command(
"git update-ref",
"HEAD changed since the commit list was loaded; refresh and try again",
));
}
Ok(())
}

pub fn push(
registry: &WorkspaceRegistry,
repo_root: &str,
Expand Down
95 changes: 95 additions & 0 deletions src-tauri/tests/git_operations.rs
Original file line number Diff line number Diff line change
Expand Up @@ -564,3 +564,98 @@ fn list_branches_keeps_current_branch_local_and_surfaces_worktrees() {
assert!(!feature[0].is_head);
assert!(feature[0].worktree_path.is_some());
}

#[test]
fn undo_last_commit_moves_changes_back_to_index() {
if skip_if_no_git() {
return;
}
let fx = GitRepoFixture::new();
fx.write_file("a.txt", "alpha\n");
fx.run_git(&["add", "a.txt"]);
fx.run_git(&["commit", "-q", "-m", "first"]);
fx.write_file("b.txt", "beta\n");
fx.run_git(&["add", "b.txt"]);
fx.run_git(&["commit", "-q", "-m", "second"]);

let entries = operations::log(&fx.registry, &fx.repo_str(), 10, None, &fx.workspace)
.expect("log");
assert_eq!(entries.len(), 2);
let head_sha = entries[0].sha.clone();

operations::undo_last_commit(&fx.registry, &fx.repo_str(), &head_sha, &fx.workspace)
.expect("undo");

let entries = operations::log(&fx.registry, &fx.repo_str(), 10, None, &fx.workspace)
.expect("log after undo");
assert_eq!(entries.len(), 1);
assert_eq!(entries[0].subject, "first");

let snap = operations::status(&fx.registry, &fx.repo_str(), &fx.workspace).unwrap();
let entry = snap
.changed_files
.iter()
.find(|f| f.path == "b.txt")
.expect("b.txt back in index");
assert!(entry.staged);
}

#[test]
fn undo_last_commit_rejects_stale_head_sha() {
if skip_if_no_git() {
return;
}
let fx = GitRepoFixture::new();
fx.write_file("a.txt", "alpha\n");
fx.run_git(&["add", "a.txt"]);
fx.run_git(&["commit", "-q", "-m", "first"]);
fx.write_file("b.txt", "beta\n");
fx.run_git(&["add", "b.txt"]);
fx.run_git(&["commit", "-q", "-m", "second"]);

// A sha that exists and has a parent but is no longer HEAD: the atomic
// compare-and-swap must refuse to move the ref.
let entries = operations::log(&fx.registry, &fx.repo_str(), 10, None, &fx.workspace)
.expect("log");
let stale_sha = entries[1].sha.clone();
let err =
operations::undo_last_commit(&fx.registry, &fx.repo_str(), &stale_sha, &fx.workspace)
.expect_err("stale sha must be rejected");
assert!(matches!(err, GitError::CommandFailed { .. }));

let err = operations::undo_last_commit(
&fx.registry,
&fx.repo_str(),
"not-a-sha",
&fx.workspace,
)
.expect_err("malformed sha must be rejected");
assert!(matches!(err, GitError::CommandFailed { .. }));

let entries = operations::log(&fx.registry, &fx.repo_str(), 10, None, &fx.workspace)
.expect("log");
assert_eq!(entries.len(), 2, "history must be untouched");
}

#[test]
fn undo_last_commit_rejects_initial_commit() {
if skip_if_no_git() {
return;
}
let fx = GitRepoFixture::new();
fx.write_file("a.txt", "alpha\n");
fx.run_git(&["add", "a.txt"]);
fx.run_git(&["commit", "-q", "-m", "first"]);

let entries = operations::log(&fx.registry, &fx.repo_str(), 10, None, &fx.workspace)
.expect("log");
let head_sha = entries[0].sha.clone();

let err = operations::undo_last_commit(&fx.registry, &fx.repo_str(), &head_sha, &fx.workspace)
.expect_err("initial commit cannot be undone");
assert!(matches!(err, GitError::CommandFailed { .. }));

let entries = operations::log(&fx.registry, &fx.repo_str(), 10, None, &fx.workspace)
.expect("log");
assert_eq!(entries.len(), 1, "history must be untouched");
}
5 changes: 5 additions & 0 deletions src/app/App.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -595,6 +595,9 @@ export default function App() {
const explorerGitDecorations = usePreferencesStore(
(s) => s.explorerGitDecorations,
);
const sourceControlUndoCommit = usePreferencesStore(
(s) => s.sourceControlUndoCommit,
);

const openPreviewTab = useCallback(
(url: string) => {
Expand Down Expand Up @@ -1136,6 +1139,8 @@ export default function App() {
onOpenGitGraph={openGitGraphFromContext}
onOpenFile={handleOpenFile}
onNavigateToPath={cdInNewTab}
onOpenCommitFile={openCommitFileDiffTab}
showUndoCommit={sourceControlUndoCommit}
/>
)}
</div>
Expand Down
6 changes: 6 additions & 0 deletions src/modules/ai/lib/native.ts
Original file line number Diff line number Diff line change
Expand Up @@ -317,6 +317,12 @@ export const native = {
message,
workspace: currentWorkspaceEnv(),
}),
gitUndoCommit: (repoRoot: string, expectedHeadSha: string) =>
invoke<void>("git_undo_commit", {
repoRoot,
expectedHeadSha,
workspace: currentWorkspaceEnv(),
}),
gitFetch: (repoRoot: string) =>
invoke<void>("git_fetch", {
repoRoot,
Expand Down
13 changes: 13 additions & 0 deletions src/modules/settings/store.ts
Original file line number Diff line number Diff line change
Expand Up @@ -146,6 +146,7 @@ export type Preferences = {
editorWordWrap: boolean;
showHidden: boolean;
explorerGitDecorations: boolean;
sourceControlUndoCommit: boolean;
terminalWebglEnabled: boolean;
terminalCursorBlink: boolean;
terminalFontFamily: string;
Expand Down Expand Up @@ -213,6 +214,7 @@ const KEY_EDITOR_WORD_WRAP = "editorWordWrap";
const KEY_SHOW_HIDDEN = "showHidden";
const LEGACY_KEY_SHOW_HIDDEN_DIRS = "showHiddenDirectories";
const KEY_EXPLORER_GIT_DECORATIONS = "explorerGitDecorations";
const KEY_SOURCE_CONTROL_UNDO_COMMIT = "sourceControlUndoCommit";
const KEY_TERMINAL_WEBGL_ENABLED = "terminalWebglEnabled";
const KEY_TERMINAL_CURSOR_BLINK = "terminalCursorBlink";
const KEY_TERMINAL_FONT_FAMILY = "terminalFontFamily";
Expand Down Expand Up @@ -281,6 +283,7 @@ export const DEFAULT_PREFERENCES: Preferences = {
editorWordWrap: false,
showHidden: false,
explorerGitDecorations: true,
sourceControlUndoCommit: true,
terminalWebglEnabled: true,
terminalCursorBlink: false,
terminalFontFamily: "",
Expand Down Expand Up @@ -417,6 +420,9 @@ export async function loadPreferences(): Promise<Preferences> {
explorerGitDecorations:
get<boolean>(KEY_EXPLORER_GIT_DECORATIONS) ??
DEFAULT_PREFERENCES.explorerGitDecorations,
sourceControlUndoCommit:
get<boolean>(KEY_SOURCE_CONTROL_UNDO_COMMIT) ??
DEFAULT_PREFERENCES.sourceControlUndoCommit,
terminalWebglEnabled:
get<boolean>(KEY_TERMINAL_WEBGL_ENABLED) ??
DEFAULT_PREFERENCES.terminalWebglEnabled,
Expand Down Expand Up @@ -650,6 +656,12 @@ export async function setExplorerGitDecorations(value: boolean): Promise<void> {
await writePref(KEY_EXPLORER_GIT_DECORATIONS, value);
}

export async function setSourceControlUndoCommit(
value: boolean,
): Promise<void> {
await writePref(KEY_SOURCE_CONTROL_UNDO_COMMIT, value);
}

export async function setTerminalWebglEnabled(value: boolean): Promise<void> {
await writePref(KEY_TERMINAL_WEBGL_ENABLED, value);
}
Expand Down Expand Up @@ -786,6 +798,7 @@ export async function onPreferencesChange(
[KEY_EDITOR_WORD_WRAP]: "editorWordWrap",
[KEY_SHOW_HIDDEN]: "showHidden",
[KEY_EXPLORER_GIT_DECORATIONS]: "explorerGitDecorations",
[KEY_SOURCE_CONTROL_UNDO_COMMIT]: "sourceControlUndoCommit",
[KEY_TERMINAL_WEBGL_ENABLED]: "terminalWebglEnabled",
[KEY_TERMINAL_CURSOR_BLINK]: "terminalCursorBlink",
[KEY_TERMINAL_FONT_FAMILY]: "terminalFontFamily",
Expand Down
Loading