Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
52 commits
Select commit Hold shift + click to select a range
41cf98e
docs: add diff hunk/block affordance design
ashproto Aug 7, 2026
3c23ce2
docs: add diff hunk affordance implementation plan
ashproto Aug 7, 2026
2a98f31
feat: add pure block/ordinal helpers for diff hunks
ashproto Aug 7, 2026
2a6144a
feat: add hunk and line discard to the working-copy ops
ashproto Aug 7, 2026
907a6c0
feat: expose hunk and line discard to the frontend
ashproto Aug 7, 2026
93b5e27
feat: add nested hunk and block hover rings to the diff view
ashproto Aug 7, 2026
6020aa0
feat: add floating stage/discard toolbar to the diff view
ashproto Aug 7, 2026
b4abb4c
fix: keep the diff toolbar reachable by clearing hover on the outer w…
ashproto Aug 7, 2026
f1419ff
feat: replace diff line selection with a contiguous range and ring
ashproto Aug 7, 2026
539796b
feat: support hunk and block rings in split diff view
ashproto Aug 7, 2026
0cfa144
fix: select paired split-view rows as a unit
ashproto Aug 7, 2026
cd5b9dc
docs: clarify Block.rows index space and cover unpaired addition rows
ashproto Aug 7, 2026
8f5d3f8
feat: add keyboard parity for diff ring selection
ashproto Aug 7, 2026
ad86a8d
fix: refuse non-contiguous line selections that would reorder the file
ashproto Aug 7, 2026
e11b9ef
fix: snap split-view selection to whole blocks
ashproto Aug 7, 2026
64e5f8e
fix: address whole-branch review findings in the diff affordance
ashproto Aug 7, 2026
d8b84c6
fix: keep the diff toolbar reachable for targets taller than the pane
ashproto Aug 7, 2026
32bd524
docs: correct the split-view selection design after implementation
ashproto Aug 7, 2026
4480001
fix: make the init-repo test fixture collision-proof under parallel t…
ashproto Aug 7, 2026
41ec3b5
fix: track which list a working-copy file was selected in
ashproto Aug 7, 2026
0981c36
fix: resolve merged untracked rows to the list they are rendered in
ashproto Aug 7, 2026
36547f7
fix: pin hunk actions to the hunk header bar
ashproto Aug 8, 2026
57202ce
fix: clear a stale init-repo fixture directory before creating it
ashproto Aug 8, 2026
eccbee2
fix: repair zero-context hunk placement and guard discard against a s…
ashproto Aug 8, 2026
8fea2c5
docs: design for scoping the native context menu to selectable text
ashproto Aug 8, 2026
a32bf4d
fix: stop a partial selection at a no-newline EOF from merging two lines
ashproto Aug 8, 2026
3513a50
fix: let the native context menu through where text is selectable
ashproto Aug 8, 2026
67db4c7
Merge pull request #25 from ashproto/fix/flaky-init-repo-tempdir
ashproto Aug 8, 2026
a6673e8
fix: reject an existing bare repository as an init destination
ashproto Aug 9, 2026
652543e
fix: stop a line or hunk discard from reverting a mode change
ashproto Aug 9, 2026
13d8ed7
fix: re-fetch the diff after a discard is refused as stale
ashproto Aug 9, 2026
2a42da3
chore: merge next into feat/diff-hunk-affordance
ashproto Aug 9, 2026
eab3226
fix: make partial discard work on an intent-to-add file, and pin diff…
ashproto Aug 9, 2026
2d0dd0c
style: merge two identical filter_map arms in discard_header
ashproto Aug 9, 2026
42ea036
chore: merge #26 into the no-newline-EOF branch
ashproto Aug 9, 2026
1440f90
Merge pull request #26 from ashproto/feat/diff-hunk-affordance
ashproto Aug 9, 2026
b8b3c16
Merge pull request #28 from ashproto/fix/no-newline-eof-partial-selec…
ashproto Aug 9, 2026
6c282fb
fix: defer an automatic update prompt for every overlay, not just dia…
ashproto Aug 9, 2026
4ddc2af
fix: reject a Git directory as the parent, and declare the Node floor
ashproto Aug 9, 2026
6856964
Merge pull request #27 from ashproto/fix/scope-native-context-menu
ashproto Aug 9, 2026
50d15f8
Merge remote-tracking branch 'origin/next' into fix/detect-bare-repo-…
ashproto Aug 9, 2026
87a0114
fix: make sidebar ref actions wait for a repo switch, and drop a supe…
ashproto Aug 9, 2026
6668251
fix: reject a parent anywhere below Git metadata, not just the Git di…
ashproto Aug 9, 2026
90f6614
Merge pull request #29 from ashproto/fix/detect-bare-repo-destination
ashproto Aug 9, 2026
f969d77
fix: show the empty state when the selected file leaves the working copy
ashproto Aug 9, 2026
02af5d3
Merge pull request #31 from ashproto/fix/clear-vanished-working-selec…
ashproto Aug 9, 2026
49cf239
fix: resolve an untracked selection into Unstaged once the lists are …
ashproto Aug 10, 2026
3d8f409
fix: re-check the repository after a ref dialog, not only before it
ashproto Aug 10, 2026
554c1c9
Merge pull request #32 from ashproto/fix/untracked-presence-under-unify
ashproto Aug 10, 2026
2709753
fix: bind a confirmed action to the repository it was confirmed against
ashproto Aug 10, 2026
f2fee34
fix: capture the repository before the first await, not before the di…
ashproto Aug 10, 2026
0c9e4ca
Merge pull request #33 from ashproto/fix/bind-confirmed-action-to-repo
ashproto Aug 10, 2026
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
3 changes: 3 additions & 0 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -10,3 +10,6 @@ node_modules
!.env.example
vite.config.js.timestamp-*
vite.config.ts.timestamp-*

# superpowers brainstorming scratch (mockups, prototypes)
.superpowers/
3 changes: 3 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -72,6 +72,9 @@ If `git` or `python3` is missing, the app shows a startup banner with an **Insta

## Build from source

Needs **Node `^20.19.0 || >=22.12.0`** (Vite 8's floor; CI builds on 24). `npm install` only warns
on an older runtime, so the failure would otherwise surface later as a confusing build error.

```sh
npm install
npm run tauri dev # run the app in development
Expand Down
154 changes: 143 additions & 11 deletions crates/git-core/src/git_ops.rs
Original file line number Diff line number Diff line change
Expand Up @@ -37,6 +37,39 @@ fn is_inside_worktree(path: &Path) -> bool {
.unwrap_or(false)
}

/// Is `path` ITSELF a Git directory? True for a bare repository's own folder — which has no
/// `.git` child, so an existence check on that never sees it — and for a normal repository's
/// `.git`. `--resolve-git-dir` answers for the path given and does NOT walk up to a parent,
/// so a plain folder that merely sits inside a repository is not mistaken for one; that case
/// belongs to `is_inside_worktree`. Callers pass an absolute path (`parent` is canonicalized
/// before the join), so the operand cannot be read as a flag.
/// Is `path` inside a repository's metadata — the Git directory itself, or anything below it?
///
/// `is_git_dir` answers only for the exact directory, so a folder picker landing on
/// `repo/.git/hooks` or `bare.git/objects` walked straight past it, and `--is-inside-work-tree` is
/// false down there as well. `--is-inside-git-dir` is documented to be true anywhere below the
/// repository directory, which is exactly the question a candidate PARENT has to answer.
/// A path in no repository at all makes git exit non-zero; that counts as false.
fn is_inside_git_dir(path: &Path) -> bool {
Command::new("git")
.current_dir(path)
.args(["rev-parse", "--is-inside-git-dir"])
.output()
.ok()
.filter(|output| output.status.success())
.map(|output| String::from_utf8_lossy(&output.stdout).trim() == "true")
.unwrap_or(false)
}

fn is_git_dir(path: &Path) -> bool {
Command::new("git")
.args(["rev-parse", "--resolve-git-dir"])
.arg(path)
.output()
.map(|output| output.status.success())
.unwrap_or(false)
}

/// Initialize a repository in one direct child of an existing parent folder.
/// A non-empty destination is reported without mutation until the caller
/// explicitly retries with `allow_non_empty`.
Expand All @@ -51,6 +84,18 @@ pub fn initialize_repository(
if !parent.is_dir() {
return Err("The selected parent path is not a folder.".to_string());
}
// A Git directory is still a folder, and `--is-inside-work-tree` answers false inside one, so
// the nesting probe below cannot catch it either — and when the destination does not exist yet
// that probe falls back to this very path. Picking `some-repo/.git`, or a bare repo, would
// therefore create the new repository inside another repository's metadata. `is_inside_git_dir`
// rather than `is_git_dir` because a picker reaches `.git/hooks` and `bare.git/objects` just as
// easily as the top of either, and an exact-path probe does not see those.
if is_inside_git_dir(&parent) {
return Err(
"The selected parent folder is a Git repository's internal directory. Choose a different folder."
.to_string(),
);
}

let name = folder_name.trim();
if name.is_empty()
Expand Down Expand Up @@ -91,7 +136,10 @@ pub fn initialize_repository(
if destination.exists() && !destination.is_dir() {
return Err("A file already exists at the requested repository path.".to_string());
}
if destination.join(".git").exists() {
// `.git` catches a normal repository; `is_git_dir` catches a bare one, which has no `.git`
// child and reports `false` for `--is-inside-work-tree`, so it slipped past both guards and
// `git init` would nest a fresh repository inside it.
if destination.join(".git").exists() || is_git_dir(&destination) {
return Err("That folder is already a Git repository. Open it instead.".to_string());
}
let nesting_probe = if destination.is_dir() {
Expand Down Expand Up @@ -339,21 +387,29 @@ pub fn check_prerequisites(filter_repo_argv: &[String]) -> PrerequisiteCheck {
#[cfg(test)]
mod tests {
use super::*;
use std::time::{SystemTime, UNIX_EPOCH};
use std::sync::atomic::{AtomicU32, Ordering};

// These tests run on parallel threads within ONE process, so the pid alone does not
// make a path unique. A wall-clock stamp did not either: two threads could read the
// same nanosecond, `fs::create_dir` then failed with AlreadyExists, and the `unwrap()`
// panicked — a flake that hit a different test on each run. A process-wide counter is
// collision-proof by construction, and is the pattern the TempRepo fixtures in ops.rs
// and ops_worktree.rs already use.
static COUNTER: AtomicU32 = AtomicU32::new(0);

struct TempFolder(PathBuf);

impl TempFolder {
fn new() -> Self {
let stamp = SystemTime::now()
.duration_since(UNIX_EPOCH)
.unwrap()
.as_nanos();
let path = std::env::temp_dir().join(format!(
"git-it-init-test-{}-{stamp}",
std::process::id()
));
fs::create_dir(&path).unwrap();
let id = COUNTER.fetch_add(1, Ordering::SeqCst);
let path = std::env::temp_dir()
.join(format!("git-it-init-test-{}-{}", std::process::id(), id));
// pid + counter is unique among LIVE processes, but not against the dead: a run
// killed before `Drop` leaves its directories behind, and once the OS recycles
// that pid a fresh process counting from zero reproduces the same path. Clear
// any such leftover first — the same guard the other TempRepo fixtures use.
let _ = fs::remove_dir_all(&path);
fs::create_dir_all(&path).unwrap();
Self(path)
}
}
Expand Down Expand Up @@ -424,4 +480,80 @@ mod tests {
assert!(err.contains("nested repository"));
assert!(!parent.0.join("nested").exists());
}

/// A bare repository has no `.git` child, and `rev-parse --is-inside-work-tree` answers
/// `false` inside one — so neither existing guard saw it. `allow_non_empty` here is the
/// dangerous path: the user is warned the folder is not empty, confirms, and `git init`
/// then creates a nested repository inside the bare one.
#[test]
fn initialize_repository_rejects_an_existing_bare_repository() {
let parent = TempFolder::new();
let mut init = Command::new("git");
init.current_dir(&parent.0).args(["init", "-q", "--bare", "shipped.git"]);
run(&mut init).unwrap();

let err = initialize_repository(&parent.0, "shipped.git", "main", true).unwrap_err();

assert!(
err.contains("already a Git repository"),
"should be refused as an existing repository, got: {err}"
);
assert!(
!parent.0.join("shipped.git").join(".git").exists(),
"must not have initialized a nested repository inside the bare one"
);
}

/// The same blind spot one level up. When the destination does not exist yet the nesting probe
/// falls back to the PARENT, and `--is-inside-work-tree` is false inside a Git directory just
/// as it is inside a bare repo — so picking `some-repo/.git` (or a bare repo) as the parent
/// created the new repository inside another repository's metadata.
#[test]
fn initialize_repository_rejects_a_git_directory_as_parent() {
let outer = TempFolder::new();

// A normal repository's `.git`, and a bare repository, are both Git directories.
let mut init = Command::new("git");
init.current_dir(&outer.0).args(["init", "-q", "host"]);
run(&mut init).unwrap();
let mut bare = Command::new("git");
bare.current_dir(&outer.0).args(["init", "-q", "--bare", "shipped.git"]);
run(&mut bare).unwrap();

// The Git directory itself, a bare repo, and — because an exact-path probe misses them —
// directories BELOW either one, which a folder picker reaches just as easily.
for parent in [
outer.0.join("host").join(".git"),
outer.0.join("host").join(".git").join("hooks"),
outer.0.join("shipped.git"),
outer.0.join("shipped.git").join("objects"),
] {
let err = initialize_repository(&parent, "proj", "main", false)
.expect_err(&format!("{} should have been refused", parent.display()));
assert!(
err.contains("Git repository"),
"{}: should be refused, got: {err}",
parent.display()
);
assert!(
!parent.join("proj").exists(),
"{}: must not have created anything inside a Git directory",
parent.display()
);
}
}

/// The guard must not over-reach: a plain folder that merely sits next to a repository
/// is still a valid destination. `--resolve-git-dir` answers for the path given and does
/// not walk up, which is what keeps this case working.
#[test]
fn initialize_repository_still_accepts_a_plain_empty_folder() {
let parent = TempFolder::new();
fs::create_dir(parent.0.join("fresh")).unwrap();

let result = initialize_repository(&parent.0, "fresh", "main", true).unwrap();

assert!(result.initialized);
assert!(parent.0.join("fresh").join(".git").is_dir());
}
}
Loading
Loading