Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
26 commits
Select commit Hold shift + click to select a range
217ca10
fix(prereq): gate history rewrite on the bundled git-filter-repo probe
ashproto Jul 16, 2026
1052e72
docs(readme): add website and license badges
ashproto Jul 16, 2026
817dfa9
fix(prereq): re-probe on window focus so a mid-session install clears…
ashproto Jul 16, 2026
a7286b2
Merge pull request #16 from ashproto/post-launch-fixes
ashproto Jul 16, 2026
ad0abc9
fix(git): self-heal stale remote-tracking ref when the remote branch …
ashproto Jul 17, 2026
ed29db7
fix(ui): show a dialog and refresh the view when a git op fails
ashproto Jul 17, 2026
2eded97
fix(github): spin only the refresh glyph, not the whole button
ashproto Jul 17, 2026
b54706d
fix(github): retry commit activity through transient cold-cache errors
ashproto Jul 17, 2026
1fada2a
fix(ui): use non-destructive ref refresh on op failure to keep queued…
ashproto Jul 17, 2026
08130b7
fix(git): pin LC_ALL=C so the stale-ref self-heal survives non-Englis…
ashproto Jul 17, 2026
9359208
fix(git): pin LC_ALL=C for auth and non-fast-forward stderr classific…
ashproto Jul 17, 2026
cb8e8f8
Merge pull request #18 from ashproto/fix/locale-pin-git-stderr
ashproto Jul 17, 2026
afeb243
Merge pull request #17 from ashproto/fix/branch-delete-and-github-ins…
ashproto Jul 17, 2026
f8a1f3d
fix(ui): retry the prerequisite probe after a transient failure
ashproto Jul 17, 2026
09d26f9
fix(ui): keep history editing disabled after a failed prerequisite probe
ashproto Jul 17, 2026
d39fb48
fix(github): don't retry explicit rate limits in commit-activity fetch
ashproto Jul 17, 2026
dc01bb6
fix(ui): serialize prerequisite probes so a stale one can't re-disabl…
ashproto Jul 17, 2026
55cea09
fix(ui): disable history editing until a successful desktop probe
ashproto Jul 17, 2026
dfb19bf
Merge pull request #20 from ashproto/fix/prereq-retry-after-failed-probe
ashproto Jul 17, 2026
4a0cfec
feat: add worktree visibility and safe deletion
ashproto Jul 17, 2026
601d070
fix: address worktree review findings
ashproto Jul 17, 2026
22a77c2
Merge pull request #21 from ashproto/feat/worktree-management
ashproto Jul 17, 2026
15e51c3
feat: improve updates, worktrees, and repository creation
ashproto Jul 18, 2026
756e269
fix: clear removed worktree tabs after branch deletion
ashproto Jul 18, 2026
0e660e0
fix: refresh remotes after GitHub repository creation
ashproto Jul 18, 2026
34dab6a
Merge pull request #22 from ashproto/agent/updater-worktrees-reposito…
ashproto Jul 18, 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
2 changes: 2 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,8 @@
[![Downloads](https://img.shields.io/github/downloads/ashproto/git-it/total)](https://github.com/ashproto/git-it/releases)
![Platform](https://img.shields.io/badge/platform-macOS%2012.3%2B-000000?logo=apple&logoColor=white)
![Built with Tauri](https://img.shields.io/badge/built%20with-Tauri%202%20%2B%20SvelteKit-24C8DB?logo=tauri&logoColor=white)
[![Website](https://img.shields.io/badge/website-git--it.app-F2542D)](https://git-it.app)
[![License: CC BY-NC-SA 4.0](https://img.shields.io/badge/license-CC%20BY--NC--SA%204.0-blue)](LICENSE)

<p align="center">
<img src="docs/screenshots/commit-graph.png" alt="Git It — the commit graph, refs sidebar, and toolbar" width="900">
Expand Down
209 changes: 208 additions & 1 deletion crates/git-core/src/git_ops.rs
Original file line number Diff line number Diff line change
@@ -1,4 +1,6 @@
use crate::types::{BundleInfo, Commit, PrerequisiteCheck, SafetyRef};
use crate::types::{
BundleInfo, Commit, InitializeRepositoryResult, PrerequisiteCheck, SafetyRef,
};
use std::fs;
use std::path::{Path, PathBuf};
use std::process::Command;
Expand All @@ -24,6 +26,121 @@ pub fn is_git_repo(repo: &Path) -> bool {
repo.join(".git").exists()
}

fn is_inside_worktree(path: &Path) -> bool {
Command::new("git")
.current_dir(path)
.args(["rev-parse", "--is-inside-work-tree"])
.output()
.ok()
.filter(|output| output.status.success())
.map(|output| String::from_utf8_lossy(&output.stdout).trim() == "true")
.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`.
pub fn initialize_repository(
parent: &Path,
folder_name: &str,
initial_branch: &str,
allow_non_empty: bool,
) -> Result<InitializeRepositoryResult, String> {
let parent = fs::canonicalize(parent)
.map_err(|error| format!("Could not open the parent folder: {error}"))?;
if !parent.is_dir() {
return Err("The selected parent path is not a folder.".to_string());
}

let name = folder_name.trim();
if name.is_empty()
|| name != folder_name
|| name == "."
|| name == ".."
|| name.contains('/')
|| name.contains('\\')
{
return Err(
"Repository name must be a single folder name without path separators."
.to_string(),
);
}
let branch = initial_branch.trim();
if branch.is_empty() || branch != initial_branch || branch.starts_with('-') {
return Err("Initial branch name is invalid.".to_string());
}
let branch_check = Command::new("git")
.current_dir(&parent)
.args(["check-ref-format", "--branch", branch])
.output()
.map_err(|error| format!("Could not validate the initial branch: {error}"))?;
if !branch_check.status.success() {
return Err("Initial branch name is invalid.".to_string());
}

let destination = parent.join(name);
if fs::symlink_metadata(&destination)
.map(|metadata| metadata.file_type().is_symlink())
.unwrap_or(false)
{
return Err(
"The requested repository path is a symbolic link. Choose the real folder instead."
.to_string(),
);
}
if destination.exists() && !destination.is_dir() {
return Err("A file already exists at the requested repository path.".to_string());
}
if destination.join(".git").exists() {
return Err("That folder is already a Git repository. Open it instead.".to_string());
}
let nesting_probe = if destination.is_dir() {
&destination
} else {
&parent
};
if is_inside_worktree(nesting_probe) {
return Err(
"Cannot create a nested repository inside another Git worktree. Choose a different parent folder."
.to_string(),
);
}

let existing_entries = if destination.is_dir() {
fs::read_dir(&destination)
.map_err(|error| format!("Could not inspect the destination folder: {error}"))?
.try_fold(0usize, |count, entry| entry.map(|_| count + 1))
.map_err(|error| format!("Could not inspect the destination folder: {error}"))?
} else {
0
};
let planned_path = destination.to_string_lossy().into_owned();
if existing_entries > 0 && !allow_non_empty {
return Ok(InitializeRepositoryResult {
path: planned_path,
initialized: false,
existing_entries,
});
}

if !destination.exists() {
fs::create_dir(&destination)
.map_err(|error| format!("Could not create the repository folder: {error}"))?;
}
let mut init = Command::new("git");
init.current_dir(&destination)
.arg("init")
.arg(format!("--initial-branch={branch}"));
run(&mut init)?;
let canonical = fs::canonicalize(&destination)
.map_err(|error| format!("Could not resolve the new repository path: {error}"))?;
Ok(InitializeRepositoryResult {
path: canonical.to_string_lossy().into_owned(),
initialized: true,
existing_entries,
})
}

pub fn load_commits(repo: &Path, count: u32, range: Option<&str>) -> Result<Vec<Commit>, String> {
// Replicates: git log --no-decorate --pretty='%H%x1f%aN%x1f%aI%x1f%cN%x1f%cI%x1f%s%x1e' -n N [RANGE]
// The \x1f field separator and \x1e record separator match the Python implementation.
Expand Down Expand Up @@ -218,3 +335,93 @@ pub fn check_prerequisites(filter_repo_argv: &[String]) -> PrerequisiteCheck {
filter_repo_version,
}
}

#[cfg(test)]
mod tests {
use super::*;
use std::time::{SystemTime, UNIX_EPOCH};

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();
Self(path)
}
}

impl Drop for TempFolder {
fn drop(&mut self) {
let _ = fs::remove_dir_all(&self.0);
}
}

#[test]
fn initialize_repository_creates_requested_folder_and_branch() {
let parent = TempFolder::new();

let result = initialize_repository(&parent.0, "project", "main", false).unwrap();

assert!(result.initialized);
assert_eq!(result.existing_entries, 0);
assert!(Path::new(&result.path).join(".git").is_dir());
let branch = Command::new("git")
.current_dir(&result.path)
.args(["symbolic-ref", "--short", "HEAD"])
.output()
.unwrap();
assert!(branch.status.success());
assert_eq!(String::from_utf8_lossy(&branch.stdout).trim(), "main");
}

#[test]
fn initialize_repository_requires_confirmation_for_existing_files() {
let parent = TempFolder::new();
let destination = parent.0.join("project");
fs::create_dir(&destination).unwrap();
fs::write(destination.join("keep.txt"), "preserve me").unwrap();

let preview = initialize_repository(&parent.0, "project", "main", false).unwrap();
assert!(!preview.initialized);
assert_eq!(preview.existing_entries, 1);
assert!(!destination.join(".git").exists());

let result = initialize_repository(&parent.0, "project", "main", true).unwrap();
assert!(result.initialized);
assert_eq!(
fs::read_to_string(destination.join("keep.txt")).unwrap(),
"preserve me"
);
}

#[test]
fn initialize_repository_rejects_invalid_branch_before_mutation() {
let parent = TempFolder::new();

let err = initialize_repository(&parent.0, "project", "-danger", false).unwrap_err();

assert!(err.contains("branch name"));
assert!(!parent.0.join("project").exists());
}

#[test]
fn initialize_repository_rejects_nested_git_worktree() {
let parent = TempFolder::new();
let mut init = Command::new("git");
init.current_dir(&parent.0).args(["init", "-q"]);
run(&mut init).unwrap();

let err = initialize_repository(&parent.0, "nested", "main", false).unwrap_err();

assert!(err.contains("nested repository"));
assert!(!parent.0.join("nested").exists());
}
}
53 changes: 53 additions & 0 deletions crates/git-core/src/github/mod.rs
Original file line number Diff line number Diff line change
Expand Up @@ -955,6 +955,15 @@ pub fn issue_create(repo: &Path, title: &str, body: &str) -> Result<String, Gith
Ok(out.trim().to_string())
}

fn has_committed_head(repo: &Path) -> Result<bool, GithubError> {
let head = Command::new("git")
.current_dir(repo)
.args(["rev-parse", "--verify", "--quiet", "HEAD^{commit}"])
.output()
.map_err(|e| GithubError::Other(format!("could not inspect HEAD: {e}")))?;
Ok(head.status.success())
}

/// Create a GitHub repo from this local repo, wire it as `origin`, and push the
/// current branch (setting upstream). TWO steps, deliberately not gh's `--push`:
/// (1) `gh repo create <name> --source=<repo> --private|--public
Expand Down Expand Up @@ -997,6 +1006,18 @@ pub fn create_repo(
// Step 1 — create the repo + add `origin` (gh API token; reliable).
let created = run_gh(&args, None)?;

// A freshly initialized repository has an unborn HEAD. The remote and
// origin are already fully created in that state, but there is no ref Git
// can push yet (`src refspec HEAD does not match any`). Treat that as a
// successful empty-repository setup and let the first normal push establish
// the upstream after the user creates a commit.
if !has_committed_head(repo)? {
return Ok(format!(
"{}\nRemote added. Create the first commit to push this repository.",
created.trim()
));
}

// Step 2 — push the current branch + set upstream. The empty `credential.helper=`
// resets any inherited (possibly broken) global helpers; the second entry forces
// gh's own credential helper so auth uses the gh token. GIT_TERMINAL_PROMPT=0 so a
Expand Down Expand Up @@ -1667,6 +1688,8 @@ pub fn runs(repo: &Path, limit: u32) -> Result<Vec<GhRun>, GithubError> {
#[cfg(test)]
mod tests {
use super::*;
use std::fs;
use std::time::{SystemTime, UNIX_EPOCH};

#[test]
fn merge_flag_maps_known_methods() {
Expand All @@ -1676,6 +1699,36 @@ mod tests {
assert_eq!(merge_flag("bogus"), None);
}

#[test]
fn committed_head_detection_distinguishes_an_empty_repository() {
let stamp = SystemTime::now()
.duration_since(UNIX_EPOCH)
.unwrap()
.as_nanos();
let path = std::env::temp_dir().join(format!(
"git-it-github-head-test-{}-{stamp}",
std::process::id()
));
fs::create_dir(&path).unwrap();
let run_git = |args: &[&str]| {
let output = Command::new("git")
.current_dir(&path)
.args(args)
.output()
.unwrap();
assert!(output.status.success(), "{}", String::from_utf8_lossy(&output.stderr));
};
run_git(&["init", "-q", "--initial-branch=main"]);
assert!(!has_committed_head(&path).unwrap());
run_git(&["config", "user.name", "Test"]);
run_git(&["config", "user.email", "test@example.com"]);
fs::write(path.join("a.txt"), "a").unwrap();
run_git(&["add", "a.txt"]);
run_git(&["commit", "-q", "-m", "initial"]);
assert!(has_committed_head(&path).unwrap());
let _ = fs::remove_dir_all(path);
}

#[test]
fn parses_all_github_url_forms() {
let cases = [
Expand Down
12 changes: 12 additions & 0 deletions crates/git-core/src/graph.rs
Original file line number Diff line number Diff line change
Expand Up @@ -262,6 +262,9 @@ fn detect_operation(repo: &Path) -> Option<String> {
if exists("REVERT_HEAD") {
return Some("revert".to_string());
}
if exists("BISECT_START") {
return Some("bisect".to_string());
}
None
}

Expand Down Expand Up @@ -527,6 +530,15 @@ mod tests {
assert_eq!(repo_status(&r.path).unwrap().operation.as_deref(), Some("merge"));
}

#[test]
fn repo_status_detects_in_progress_bisect() {
let r = TempRepo::new();
r.write_commit("base.txt", "base");
r.git(&["bisect", "start"]);

assert_eq!(repo_status(&r.path).unwrap().operation.as_deref(), Some("bisect"));
}

#[test]
fn detect_operation_resolves_linked_worktree_gitdir() {
let r = TempRepo::new();
Expand Down
Loading
Loading