diff --git a/README.md b/README.md
index e6cdc9f..1c42534 100644
--- a/README.md
+++ b/README.md
@@ -5,6 +5,8 @@
[](https://github.com/ashproto/git-it/releases)


+[](https://git-it.app)
+[](LICENSE)
diff --git a/crates/git-core/src/git_ops.rs b/crates/git-core/src/git_ops.rs
index 62307ac..4261a24 100644
--- a/crates/git-core/src/git_ops.rs
+++ b/crates/git-core/src/git_ops.rs
@@ -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;
@@ -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 {
+ 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, 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.
@@ -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());
+ }
+}
diff --git a/crates/git-core/src/github/mod.rs b/crates/git-core/src/github/mod.rs
index 8777e99..e642b4f 100644
--- a/crates/git-core/src/github/mod.rs
+++ b/crates/git-core/src/github/mod.rs
@@ -955,6 +955,15 @@ pub fn issue_create(repo: &Path, title: &str, body: &str) -> Result Result {
+ 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 --source= --private|--public
@@ -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
@@ -1667,6 +1688,8 @@ pub fn runs(repo: &Path, limit: u32) -> Result, GithubError> {
#[cfg(test)]
mod tests {
use super::*;
+ use std::fs;
+ use std::time::{SystemTime, UNIX_EPOCH};
#[test]
fn merge_flag_maps_known_methods() {
@@ -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 = [
diff --git a/crates/git-core/src/graph.rs b/crates/git-core/src/graph.rs
index d572767..ff526a9 100644
--- a/crates/git-core/src/graph.rs
+++ b/crates/git-core/src/graph.rs
@@ -262,6 +262,9 @@ fn detect_operation(repo: &Path) -> Option {
if exists("REVERT_HEAD") {
return Some("revert".to_string());
}
+ if exists("BISECT_START") {
+ return Some("bisect".to_string());
+ }
None
}
@@ -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();
diff --git a/crates/git-core/src/ops.rs b/crates/git-core/src/ops.rs
index 47df93d..5d89f9c 100644
--- a/crates/git-core/src/ops.rs
+++ b/crates/git-core/src/ops.rs
@@ -1,6 +1,811 @@
use crate::git_ops;
-use std::path::Path;
-use std::process::Command;
+use crate::graph;
+use crate::types::WorktreeInfo;
+use std::fs;
+use std::fs::OpenOptions;
+use std::io::{BufRead, BufReader, ErrorKind, Write};
+use std::path::{Path, PathBuf};
+use std::process::{Child, ChildStdin, ChildStdout, Command, Stdio};
+use std::str;
+
+#[derive(Debug, Default)]
+struct ParsedWorktree {
+ seen: bool,
+ path: String,
+ head: Option,
+ branch: Option,
+ detached: bool,
+ bare: bool,
+ locked: bool,
+ locked_reason: Option,
+ prunable: bool,
+ prunable_reason: Option,
+}
+
+fn finish_worktree(
+ current: &mut ParsedWorktree,
+ out: &mut Vec,
+) -> Result<(), String> {
+ if !current.seen {
+ return Ok(());
+ }
+ if current.path.is_empty() {
+ return Err("git worktree output contained a record without a path".to_string());
+ }
+ out.push(std::mem::take(current));
+ Ok(())
+}
+
+/// Parse the stable NUL-delimited worktree porcelain format. Splitting each
+/// field at only its first space preserves spaces and newlines inside paths and
+/// lock/prune reasons. Invalid UTF-8 is rejected instead of being made lossy:
+/// this path may later identify a destructive removal target.
+fn parse_worktree_porcelain(raw: &[u8]) -> Result, String> {
+ let mut out = Vec::new();
+ let mut current = ParsedWorktree::default();
+
+ for field in raw.split(|b| *b == 0) {
+ if field.is_empty() {
+ finish_worktree(&mut current, &mut out)?;
+ continue;
+ }
+ let split = field.iter().position(|b| *b == b' ');
+ let (key_raw, value_raw) = match split {
+ Some(i) => (&field[..i], &field[i + 1..]),
+ None => (field, &[][..]),
+ };
+ let key = str::from_utf8(key_raw)
+ .map_err(|_| "git worktree output contained an invalid UTF-8 field name".to_string())?;
+ let value = str::from_utf8(value_raw)
+ .map_err(|_| format!("git worktree {key} contained an invalid UTF-8 value"))?;
+
+ // Be tolerant of a missing blank separator while still keeping records
+ // isolated if a future Git version starts a new `worktree` field.
+ if key == "worktree" && current.seen {
+ finish_worktree(&mut current, &mut out)?;
+ }
+ current.seen = true;
+ match key {
+ "worktree" => current.path = value.to_string(),
+ "HEAD" => current.head = Some(value.to_string()),
+ "branch" => current.branch = value.strip_prefix("refs/heads/").map(str::to_string),
+ "detached" => current.detached = true,
+ "bare" => current.bare = true,
+ "locked" => {
+ current.locked = true;
+ current.locked_reason = (!value.is_empty()).then(|| value.to_string());
+ }
+ "prunable" => {
+ current.prunable = true;
+ current.prunable_reason = (!value.is_empty()).then(|| value.to_string());
+ }
+ _ => {} // forward-compatible: ignore fields added by future Git versions
+ }
+ }
+ finish_worktree(&mut current, &mut out)?;
+ Ok(out)
+}
+
+fn same_existing_path(a: &Path, b: &Path) -> bool {
+ match (fs::canonicalize(a), fs::canonicalize(b)) {
+ (Ok(a), Ok(b)) => a == b,
+ _ => a == b,
+ }
+}
+
+struct WorktreeHeadLock {
+ path: PathBuf,
+ // Keeping the descriptor open makes ownership of the standard Git lock
+ // unambiguous until removal finishes.
+ _file: fs::File,
+}
+
+struct BranchDeleteSafety {
+ branch_oid: String,
+ // Verify-only entries held by the prepared transaction. Some assert a ref's
+ // exact OID; `None` asserts that a configured-but-gone upstream stays absent.
+ verifications: Vec<(String, Option)>,
+}
+
+/// A prepared `git update-ref` transaction holds the branch ref lock while the
+/// worktree folder is removed. The expected old OID makes a concurrent ref
+/// update fail before any filesystem deletion, and dropping an uncommitted
+/// transaction aborts it so the branch remains intact.
+struct PreparedBranchDelete {
+ child: Child,
+ stdin: Option,
+ stdout: BufReader,
+ finished: bool,
+}
+
+impl PreparedBranchDelete {
+ fn prepare(
+ repo: &Path,
+ refname: &str,
+ expected_oid: &str,
+ verifications: &[(String, Option)],
+ ) -> Result {
+ let mut child = Command::new("git")
+ .current_dir(repo)
+ .env("LC_ALL", "C")
+ .args(["update-ref", "--stdin"])
+ .stdin(Stdio::piped())
+ .stdout(Stdio::piped())
+ .stderr(Stdio::piped())
+ .spawn()
+ .map_err(|e| format!("Failed to start the branch delete transaction: {e}"))?;
+ let stdin = child
+ .stdin
+ .take()
+ .ok_or_else(|| "Git did not open the branch transaction input.".to_string())?;
+ let stdout = child
+ .stdout
+ .take()
+ .ok_or_else(|| "Git did not open the branch transaction output.".to_string())?;
+ let mut transaction = Self {
+ child,
+ stdin: Some(stdin),
+ stdout: BufReader::new(stdout),
+ finished: false,
+ };
+ transaction.send("start")?;
+ transaction.expect_response("start: ok")?;
+ transaction.send(&format!("delete {refname} {expected_oid}"))?;
+ let zero_oid = "0".repeat(expected_oid.len());
+ for (verification_ref, expected_oid) in verifications {
+ match expected_oid {
+ Some(oid) => transaction.send(&format!("verify {verification_ref} {oid}"))?,
+ // An all-zero old OID asserts that the ref does not exist. Match
+ // the repository's object format (SHA-1 or SHA-256) by deriving
+ // the width from the branch OID already being deleted.
+ None => transaction.send(&format!("verify {verification_ref} {zero_oid}"))?,
+ }
+ }
+ transaction.send("prepare")?;
+ transaction.expect_response("prepare: ok")?;
+ Ok(transaction)
+ }
+
+ fn send(&mut self, command: &str) -> Result<(), String> {
+ let stdin = self
+ .stdin
+ .as_mut()
+ .ok_or_else(|| "The branch transaction is already closed.".to_string())?;
+ writeln!(stdin, "{command}")
+ .and_then(|_| stdin.flush())
+ .map_err(|e| format!("Could not communicate with the branch transaction: {e}"))
+ }
+
+ fn expect_response(&mut self, expected: &str) -> Result<(), String> {
+ let mut response = String::new();
+ let read = self
+ .stdout
+ .read_line(&mut response)
+ .map_err(|e| format!("Could not read the branch transaction response: {e}"))?;
+ if read == 0 {
+ return Err("Git ended the branch transaction unexpectedly.".to_string());
+ }
+ if response.trim_end() != expected {
+ return Err(format!(
+ "Git returned an unexpected branch transaction response: {}",
+ response.trim_end()
+ ));
+ }
+ Ok(())
+ }
+
+ fn commit(mut self) -> Result<(), String> {
+ self.send("commit")?;
+ self.expect_response("commit: ok")?;
+ self.stdin.take();
+ let status = self
+ .child
+ .wait()
+ .map_err(|e| format!("Could not finish the branch transaction: {e}"))?;
+ self.finished = true;
+ if status.success() {
+ Ok(())
+ } else {
+ Err("Git could not commit the prepared branch deletion.".to_string())
+ }
+ }
+}
+
+impl Drop for PreparedBranchDelete {
+ fn drop(&mut self) {
+ if self.finished {
+ return;
+ }
+ let _ = self.send("abort");
+ let _ = self.expect_response("abort: ok");
+ self.stdin.take();
+ let _ = self.child.wait();
+ }
+}
+
+impl Drop for WorktreeHeadLock {
+ fn drop(&mut self) {
+ // A successful `git worktree remove` deletes the administrative
+ // directory (and therefore this lock) for us. On any earlier failure,
+ // release only the lock file we created.
+ if self.path.exists() {
+ let _ = fs::remove_file(&self.path);
+ }
+ }
+}
+
+fn git_path_output(repo: &Path, args: &[&str], description: &str) -> Result {
+ let output = Command::new("git")
+ .current_dir(repo)
+ .args(args)
+ .output()
+ .map_err(|e| format!("Failed to resolve {description}: {e}"))?;
+ if !output.status.success() {
+ let stderr = String::from_utf8_lossy(&output.stderr);
+ return Err(if stderr.trim().is_empty() {
+ format!("Could not resolve {description}.")
+ } else {
+ stderr.into_owned()
+ });
+ }
+ let stdout = str::from_utf8(&output.stdout)
+ .map_err(|_| format!("Git returned an invalid UTF-8 {description}."))?;
+ // Remove exactly Git's record terminator, preserving any newline that is
+ // actually part of the path.
+ let value = stdout.strip_suffix('\n').unwrap_or(stdout);
+ let value = value.strip_suffix('\r').unwrap_or(value);
+ if value.is_empty() {
+ return Err(format!("Git returned an empty {description}."));
+ }
+ let path = PathBuf::from(value);
+ if !path.is_absolute() {
+ return Err(format!("Git returned a non-absolute {description}."));
+ }
+ fs::canonicalize(&path).map_err(|e| format!("Could not verify {description}: {e}"))
+}
+
+fn acquire_worktree_head_lock(repo: &Path, worktree: &Path) -> Result {
+ let repo_common = git_path_output(
+ repo,
+ &["rev-parse", "--path-format=absolute", "--git-common-dir"],
+ "repository Git directory",
+ )?;
+ let worktree_common = git_path_output(
+ worktree,
+ &["rev-parse", "--path-format=absolute", "--git-common-dir"],
+ "worktree Git directory",
+ )?;
+ if repo_common != worktree_common {
+ return Err(
+ "The requested path is no longer a worktree of this repository. Refresh and try again."
+ .to_string(),
+ );
+ }
+ let admin_dir = git_path_output(
+ worktree,
+ &["rev-parse", "--absolute-git-dir"],
+ "worktree administrative directory",
+ )?;
+ let lock_path = admin_dir.join("HEAD.lock");
+ let file = OpenOptions::new()
+ .write(true)
+ .create_new(true)
+ .open(&lock_path)
+ .map_err(|e| {
+ if e.kind() == ErrorKind::AlreadyExists {
+ "Another Git operation is using this worktree. Wait for it to finish, then try again."
+ .to_string()
+ } else {
+ format!("Could not lock the worktree for safe removal: {e}")
+ }
+ })?;
+ Ok(WorktreeHeadLock {
+ path: lock_path,
+ _file: file,
+ })
+}
+
+fn exact_ref_oid_if_exists(repo: &Path, refname: &str) -> Result