Something went wrong. Try again.
atproto git client
Something went wrong. Try again.
Rust
123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594595596597598599600601602603604605606607608609610611612613614615616617//! Reading and writing git's configuration, and the paths it reports.//!//! Everything atgc knows about a checkout that is not its commits: the local//! `[user]` identity a Tangled repo carries, the `core.sshCommand` that pins//! which key a push authenticates with, the remotes, and where the git//! directory actually is once worktrees are involved.//!//! These are primitives. What to write into `[user]`, and whether an identity//! already there should be replaced, is a decision `repo configure` makes —//! it lives with the command and calls in here.
use super::run::git_in;use anyhow::Result;use std::path::{Path, PathBuf};
/// A repo-local config value from `dir`, if that is a repo and the key is/// set there.////// `--local` deliberately, for two reasons that agree. The account selector/// reads `user.email`, and a DID sitting in someone's global git config/// would follow them into every checkout, which is the opposite of what a/// per-repo identity is for. And the identity writer uses "is it already/// set?" to decide whether writing would clobber a deliberate choice — where/// a value inherited from ~/.gitconfig is precisely *not* this repo's choice/// and must read as absent. Any failure (not a repo, key unset, empty value)/// is just "no value".pub fn local_config(dir: &Path, key: &str) -> Option<String> { git_in(dir, &["config", "--local", "--get", key]) .ok() .filter(|v| !v.is_empty())}
/// Which of a checkout's two config files a value came from, or is going to.////// The distinction exists because a linked worktree has two, and they mean/// different things: `config.worktree` belongs to this worktree alone, while/// `.git/config` is shared with every other worktree of the repo. For an/// identity that is the whole question — a value in the shared file makes/// every worktree the same account.#[derive(Debug, Clone, Copy, PartialEq, Eq)]pub enum Scope { /// `.git/worktrees/<name>/config.worktree`, private to this worktree. Worktree, /// `.git/config`, shared by every worktree of the repo. Repo,}
impl Scope { /// The `git config` flag that reads and writes it. fn flag(self) -> &'static str { match self { Scope::Worktree => "--worktree", Scope::Repo => "--local", } }
/// How to name it to somebody who has to act on it. pub fn describe(self) -> &'static str { match self { Scope::Worktree => "this worktree's own git config", Scope::Repo => "this repo's .git/config, shared by every worktree", } }}
/// A config value from one named scope of `dir`.////// `--worktree` fails rather than answering empty on a git that predates it/// (2.20) and on a repo without `extensions.worktreeConfig`, and both of/// those are "no value" here — the caller that needs to tell them apart is/// the *writer*, which says so with a real error.pub fn scoped_config(dir: &Path, scope: Scope, key: &str) -> Option<String> { git_in(dir, &["config", scope.flag(), "--get", key]) .ok() .filter(|v| !v.is_empty())}
/// A checkout's own value for `key`, and which file it came from.////// The worktree's own file first, then the shared one — git's own order, and/// that agreement is the point rather than a nicety. `git commit` resolves/// `user.email` this way, so a reader that consulted only `--local` would/// have atgc acting as one account while git authored as another, in the same/// directory, with nothing saying so.////// Global config is still never consulted, at either rank, for the two/// reasons [`local_config`] gives.pub fn checkout_config(dir: &Path, key: &str) -> Option<(String, Scope)> { scoped_config(dir, Scope::Worktree, key) .map(|v| (v, Scope::Worktree)) .or_else(|| scoped_config(dir, Scope::Repo, key).map(|v| (v, Scope::Repo)))}
/// Write a config value into one named scope.////// `--worktree` needs `extensions.worktreeConfig` switched on for the repo;/// [`enable_worktree_config`] is what does that, and callers turn it on/// before writing rather than discovering the refusal here.pub fn set_scoped_config(dir: &Path, scope: Scope, key: &str, value: &str) -> Result<()> { git_in(dir, &["config", scope.flag(), key, value]).map(|_| ())}
/// Switch on per-worktree configuration for the repo containing `dir`.////// Repo-wide and therefore `--local`, which is the one thing about this that/// looks contradictory: the switch that makes per-worktree values possible is/// itself shared, because git has to know to look for those files from every/// worktree. It is additive — every existing value stays in `.git/config` and/// stays visible everywhere — and it is what `git worktree` documents for/// exactly this case.////// Fails on a git older than 2.20, which is the answer this returns rather/// than a version parse: asking git to do it is a more reliable test of/// whether git can than comparing numbers, and the failure carries git's own/// words.pub fn enable_worktree_config(dir: &Path) -> Result<()> { if scoped_config(dir, Scope::Repo, "extensions.worktreeConfig") .is_some_and(|v| v.eq_ignore_ascii_case("true")) { return Ok(()); } set_scoped_config(dir, Scope::Repo, "extensions.worktreeConfig", "true")?; // Proof, and the only reliable kind: do the thing. // // Reading is no test. `--worktree --get` of a key that lives in the // shared file exits "not found" on a git that supports the scope and // "fatal" on one that does not, and `--worktree --list` fails on both // until `config.worktree` exists at all. So a throwaway key is written // and removed, which fails exactly when the write that follows would — // before an identity is half-written to the wrong file. // // What it leaves behind is the empty `config.worktree` the write needs // anyway. `--unset` of a key that was just set cannot fail for a reason // the caller could act on, so its result is not carried up. const PROBE: &str = "atgc.worktreeconfigprobe"; set_scoped_config(dir, Scope::Worktree, PROBE, "1").map_err(|e| { anyhow::anyhow!( "this git cannot write per-worktree configuration \ (`git config --worktree`, which needs git 2.20)\n{e}" ) })?; let _ = git_in(dir, &["config", "--worktree", "--unset", PROBE]); Ok(())}
/// The branch a fresh `git init` here would name, out of the whole config/// chain rather than one repo's file — `init.defaultBranch` is a preference/// people set globally if they set it at all.////// `main` when it is unset, which is not git's own fallback: git says/// `master` and prints a hint about it. Tangled's web UI creates repos on/// `main`, and a repo `atgc` creates should not depend on which of the two/// the machine happens to prefer having been spelled out.pub fn default_branch() -> String { git_in(Path::new("."), &["config", "--get", "init.defaultBranch"]) .ok() .filter(|v| !v.is_empty()) .unwrap_or_else(|| "main".to_string())}
/// Write a repo-local config value. Also `--local` on purpose: without it git/// picks the file by context, and atgc has no business editing a user's/// global or system config on their behalf.pub fn set_local_config(dir: &Path, key: &str, value: &str) -> Result<()> { git_in(dir, &["config", "--local", key, value]).map(|_| ())}
/// The file `--local` reads and writes for `dir`, and — because git only/// answers this from inside a repo — the check for "is this a repo at all?".////// `repo configure` asks first, so that being in the wrong directory fails/// with one sentence instead of with git's error attached to whichever config/// call happened to run first. The path is worth printing rather than/// assuming: in a *linked worktree* it is not this worktree's git directory/// but the shared one, so a write here is visible from every worktree of the/// repo. `--git-path` is what knows that; constructing `.git/config` by hand/// would quietly get it wrong.////// A bare repo answers this fine and is configured like any other. It can/// still fail there for a reason of git's own — `safe.bareRepository =/// explicit` refuses bare repos outright — which is why git's line is carried/// through rather than replaced: "not a repository" and "a repository this/// git will not touch" need different fixes, and only git knows which it is./// Which directory, for the same reason every other checkout failure names/// one: `repo configure` is run by hand in whatever the shell happens to be/// sitting in. `Usage` too — the checkout is the argument this command takes,/// and running it again from here cannot work./// The file one scope reads and writes for `dir`.////// `--git-path` for both, because only git knows where either lands: in a/// linked worktree `config` resolves to the *shared* file while/// `config.worktree` resolves under this worktree's own git directory, and/// constructing either by hand gets one of them wrong.pub fn scoped_config_path(dir: &Path, scope: Scope) -> Result<PathBuf> { match scope { Scope::Repo => local_config_path(dir), Scope::Worktree => Ok(PathBuf::from(git_in( dir, &[ "rev-parse", "--path-format=absolute", "--git-path", "config.worktree", ], )?)), }}
pub fn local_config_path(dir: &Path) -> Result<PathBuf> { let path = git_in(dir, &["rev-parse", "--git-path", "config"]).map_err(|e| { crate::exit::fail( crate::exit::Exit::Usage, format!( "no git repository at {}; `atgc repo configure` works on a checkout\n{e}", super::run::shown(dir) ), ) })?; Ok(absolute(dir, &path))}
/// A path git handed back, made absolute and comparable.////// `rev-parse --git-path` answers relative to the repo root when the process/// is standing at the root and absolutely otherwise, so the same repo yields/// two different strings depending on where you ask from. Canonicalizing/// settles both that and the symlinks that make `/tmp` and `/var` differ on/// some machines — which matters because two of these get compared to decide/// whether a checkout is a linked worktree. Falling back to the merely-joined/// path keeps a file that does not exist yet printable.fn absolute(dir: &Path, path: &str) -> PathBuf { let joined = if Path::new(path).is_absolute() { PathBuf::from(path) } else { dir.join(path) }; std::fs::canonicalize(&joined).unwrap_or(joined)}
/// This checkout's own git directory.pub fn git_dir(dir: &Path) -> Option<PathBuf> { git_in(dir, &["rev-parse", "--absolute-git-dir"]) .ok() .map(|p| absolute(dir, &p))}
/// Whether this is a *linked* worktree — one made by `git worktree add`,/// whose `.git` is a file rather than a directory.////// Worth asking because `git config --local` in a linked worktree does not/// write anything local to that worktree: it writes the config shared by the/// whole repo, so an identity set there is the identity of every worktree./// That is git's design, and it used to be the end of the story here — this/// comment said the only thing to do about it was say so.////// There is something to do about it: `extensions.worktreeConfig` and the/// `--worktree` scope, which is what [`Scope::Worktree`] writes and/// [`checkout_config`] reads first. This function is how everything else/// knows which of the two situations it is in — whether a shared identity is/// the only kind available, or a deliberate choice not to have one.////// `--git-common-dir` is the half that names the shared directory; in an/// ordinary checkout the two are the same path.pub fn is_linked_worktree(dir: &Path) -> bool { let common = git_in(dir, &["rev-parse", "--git-common-dir"]) .ok() .map(|p| absolute(dir, &p)); match (git_dir(dir), common) { (Some(own), Some(common)) => own != common, _ => false, }}
/// The working tree's root. `None` for a bare repo, which has none — that is/// the distinction, not an error.pub fn toplevel(dir: &Path) -> Option<PathBuf> { git_in(dir, &["rev-parse", "--show-toplevel"]) .ok() .filter(|p| !p.is_empty()) .map(PathBuf::from)}
/// Every remote URL in this checkout.////// Used only to ask "does this look like a Tangled repo?", so a remote that/// cannot be listed is silently no remote — the answer feeds a warning, never/// a refusal.pub fn remote_urls(dir: &Path) -> Vec<String> { git_in(dir, &["remote", "-v"]) .map(|out| { let mut urls: Vec<String> = out .lines() .filter_map(|line| line.split_whitespace().nth(1)) .map(str::to_string) .collect(); // `remote -v` lists fetch and push separately, usually identical. urls.dedup(); urls }) .unwrap_or_default()}
/// Every remote this checkout has, paired with its fetch URL.////// `repo configure`'s remote rewrite is the one caller: it needs the name/// back, since rewriting a stale URL means `git remote set-url <name> ...`,/// not just noticing the URL is stale. `git remote -v` lists a fetch line/// and a push line for each remote; only the fetch one is kept; a remote/// with a separate push URL is rewritten there too by/// [`super::run::set_remote_url`] leaving the push URL alone, the same/// asymmetry [`super::run::push_url`] documents.pub fn remote_name_urls(dir: &Path) -> Vec<(String, String)> { git_in(dir, &["remote", "-v"]) .map(|out| { let mut seen = std::collections::HashSet::new(); out.lines() .filter(|line| line.trim_end().ends_with("(fetch)")) .filter_map(|line| { let mut parts = line.split_whitespace(); let name = parts.next()?; let url = parts.next()?; seen.insert(name.to_string()) .then(|| (name.to_string(), url.to_string())) }) .collect() }) .unwrap_or_default()}
#[cfg(test)]mod tests { use super::super::run::add_remote; use super::*; use crate::testutil::TempRepo;
fn here() -> &'static Path { Path::new(".") }
/// `repo create` pins the SSH key with `core.sshCommand`. The write has /// to be local to the checkout — a global one would follow the user to /// every other repo on the machine. #[test] fn set_config_writes_only_to_the_repo_it_is_run_in() { let repo = TempRepo::new("config"); repo.commit("a.txt", "one\n", "Add a");
let ssh = "ssh -o IdentitiesOnly=yes -i \"/tmp/atgc-test-key\""; set_local_config(here(), "core.sshCommand", ssh).unwrap();
let config = repo.local_config(); assert!(config.contains("sshCommand"), "config: {config}"); assert!(config.contains("/tmp/atgc-test-key"), "config: {config}"); // And git agrees it is local rather than inherited. assert_eq!(repo.git(&["config", "--local", "core.sshCommand"]), ssh); } /// `repo clone` configures a checkout it has just created, which is never /// the directory the process is in. Both the read and the write have to /// address that checkout by path and leave the surrounding repo alone. #[test] fn configures_a_checkout_that_is_not_the_current_directory() { let repo = TempRepo::new("other-dir-config"); repo.commit("a.txt", "one\n", "Add a"); // A second repo beside the first, standing in for a fresh clone. repo.git(&["init", "-q", "-b", "main", "checkout"]); let checkout = Path::new("checkout");
assert_eq!(local_config(checkout, "user.email"), None);
set_local_config(checkout, "user.name", "@permadeath.com").unwrap(); set_local_config(checkout, "user.email", "did:plc:nlzmjyfv6loqtxyzvdcznwgf").unwrap();
assert_eq!( local_config(checkout, "user.name").as_deref(), Some("@permadeath.com") ); assert_eq!( local_config(checkout, "user.email").as_deref(), Some("did:plc:nlzmjyfv6loqtxyzvdcznwgf") ); // The repo the process is standing in is untouched — which is what // stops a clone from rewriting the identity of the checkout it was // run from. assert_eq!(local_config(here(), "user.email"), None); } /// `--local` is the whole point: the account selector reads `user.email` /// to decide which identity a checkout belongs to, and a value inherited /// from `~/.gitconfig` must read as absent. That cannot be asserted /// directly without knowing what the machine's global config says, so it /// is asserted the other way — a key that is set globally on many /// machines still comes back None here until this repo sets it. #[test] fn local_config_reads_only_this_repos_values() { let repo = TempRepo::new("local-config"); repo.commit("a.txt", "one\n", "Add a");
assert_eq!(local_config(here(), "atgc.testKeyThatIsNeverSet"), None);
set_local_config(here(), "user.email", "did:plc:nlzmjyfv6loqtxyzvdcznwgf").unwrap(); assert_eq!( local_config(here(), "user.email").as_deref(), Some("did:plc:nlzmjyfv6loqtxyzvdcznwgf") ); // An empty value is not a value — the selector treats it as unset // rather than as a DID of "", and the identity writer treats it as // absent rather than as a conflict to refuse over. set_local_config(here(), "user.email", "").unwrap(); assert_eq!(local_config(here(), "user.email"), None); } /// `repo configure` asks for this first, so that "you are not in a repo" /// is one sentence rather than git's complaint attached to a config read. /// The path it hands back is the file `--local` actually writes, which is /// what makes it safe to print — and it must work from a subdirectory, /// since nobody stands at the repo root to run a command. #[test] fn finds_the_config_local_writes_to_from_anywhere_in_the_repo() { let repo = TempRepo::new("config-path"); repo.commit("a.txt", "one\n", "Add a"); set_local_config(here(), "user.email", "did:plc:nlzmjyfv6loqtxyzvdcznwgf").unwrap();
let path = local_config_path(here()).expect("inside a repo"); assert!(path.ends_with(".git/config"), "{}", path.display()); let contents = std::fs::read_to_string(&path).expect("the config git named"); assert!(contents.contains("did:plc:"), "{contents}");
// From a subdirectory: git answers about the enclosing repo, and the // path stays the repo's config rather than becoming relative nonsense. std::fs::create_dir_all("src/deep").unwrap(); let from_below = local_config_path(Path::new("src/deep")).expect("still inside the repo"); assert_eq!( std::fs::canonicalize(&from_below).unwrap(), std::fs::canonicalize(&path).unwrap() ); assert_eq!( local_config(Path::new("src/deep"), "user.email").as_deref(), Some("did:plc:nlzmjyfv6loqtxyzvdcznwgf") ); } /// Outside a repo it is an error carrying advice, not a panic and not a /// silent write into somebody's global config. #[test] fn there_is_no_local_config_outside_a_repo() { let repo = TempRepo::new("config-path-none"); repo.commit("a.txt", "one\n", "Add a"); // `temp_dir()` itself: the parent of the throwaway repo, and not a // repo unless the machine's temp directory is inside one. let outside = std::env::temp_dir(); if local_config_path(&outside).is_ok() { // Someone's /tmp is inside a git repo. Nothing to assert. return; } let err = local_config_path(&outside).unwrap_err(); let message = err.to_string(); assert!(message.contains("no git repository at"), "{message}"); assert!(message.contains("repo configure"), "{message}"); // And it says *which* directory, which is the whole diagnosis when // the shell is not where its operator thinks it is. let resolved = outside.canonicalize().expect("the temp directory exists"); assert!( message.contains(&*resolved.to_string_lossy()), "{message} does not name {}", resolved.display() ); assert_eq!(crate::exit::classify(&err), crate::exit::Exit::Usage); } /// The working tree root, and the git directory beneath it. #[test] fn locates_the_working_tree_and_the_git_directory() { let repo = TempRepo::new("toplevel"); repo.commit("a.txt", "one\n", "Add a");
let root = toplevel(here()).expect("a working tree"); let git_dir = git_dir(here()).expect("a git directory"); assert_eq!(git_dir.parent(), Some(root.as_path())); // The config `--local` writes lives inside it, in an ordinary repo. assert_eq!( local_config_path(here()).unwrap().parent(), Some(git_dir.as_path()) ); } /// A linked worktree shares its `.git/config` with the repo it was made /// from, so an identity written in one is the identity of all of them. /// This project's own workflow runs entirely in worktrees, so getting the /// detection wrong means either a warning on every ordinary repo or /// silence in the case that needs it — and the first version did the /// former, because git answers `--git-path` relatively at the repo root /// and absolutely everywhere else. /// The whole point, end to end: two worktrees of one repo, two identities. /// /// This is what `--local` cannot express and what the account selector /// now depends on. The shared file keeps answering for the main checkout /// and for any worktree that has not been given one of its own, so the /// two ranks coexist rather than one replacing the other. #[test] fn a_worktree_can_hold_an_identity_of_its_own() { let repo = TempRepo::new("worktree-scope"); repo.commit("a.txt", "one\n", "Add a"); set_scoped_config(here(), Scope::Repo, "user.email", "did:plc:shared").unwrap(); repo.git(&["worktree", "add", "-q", "-b", "side", "linked"]); let linked = Path::new("linked");
// Before it is given one, the worktree answers with the shared value // and says so — that is the state the account selector warns about. assert_eq!( checkout_config(linked, "user.email"), Some(("did:plc:shared".to_string(), Scope::Repo)) );
enable_worktree_config(linked).unwrap(); set_scoped_config(linked, Scope::Worktree, "user.email", "did:plc:agent").unwrap();
assert_eq!( checkout_config(linked, "user.email"), Some(("did:plc:agent".to_string(), Scope::Worktree)), "the worktree's own file outranks the shared one" ); assert_eq!( checkout_config(here(), "user.email"), Some(("did:plc:shared".to_string(), Scope::Repo)), "and the main checkout is untouched by it" ); // The agreement that matters: git resolves it the same way, so a // commit made here is authored by the account atgc is acting as. // // Asked through `git_in` rather than the test harness's runner, which // injects `-c user.email=` on every call so that commits in a temp // repo have an author. A command-line override outranks every file // and would answer this question with its own argument. assert_eq!( git_in(linked, &["config", "--get", "user.email"]).unwrap(), "did:plc:agent" ); }
/// A value the worktree does not set falls through to the shared file /// rather than to `~/.gitconfig`. /// /// The exclusion `local_config` documents, restated for the two-rank /// reader: a DID in somebody's global config must not become an identity /// here, at either rank. #[test] fn the_global_config_is_still_never_consulted() { let repo = TempRepo::new("worktree-global"); repo.commit("a.txt", "one\n", "Add a"); repo.git(&["worktree", "add", "-q", "-b", "side", "linked"]); let linked = Path::new("linked"); enable_worktree_config(linked).unwrap(); // Nothing set at either repo rank, and the harness points // GIT_CONFIG_GLOBAL at a file with an identity in it. assert_eq!(checkout_config(linked, "user.email"), None); assert_eq!(checkout_config(here(), "user.email"), None); }
#[test] fn spots_a_linked_worktree_and_only_a_linked_worktree() { let repo = TempRepo::new("worktree"); repo.commit("a.txt", "one\n", "Add a");
assert!( !is_linked_worktree(here()), "an ordinary checkout is not one" ); // Asked from a subdirectory too, which is where the relative/absolute // discrepancy used to show up. std::fs::create_dir_all("src/deep").unwrap(); assert!(!is_linked_worktree(Path::new("src/deep")));
repo.git(&["worktree", "add", "-q", "-b", "side", "linked"]); let linked = Path::new("linked"); assert!(is_linked_worktree(linked), "a linked worktree is one");
// And the reason it matters: `--local` there names the shared config, // not anything under the worktree's own git directory. let shared = local_config_path(linked).unwrap(); assert_eq!(shared, local_config_path(here()).unwrap()); assert_ne!(git_dir(linked), git_dir(here()));
set_local_config(linked, "user.email", "did:plc:nlzmjyfv6loqtxyzvdcznwgf").unwrap(); assert_eq!( local_config(here(), "user.email").as_deref(), Some("did:plc:nlzmjyfv6loqtxyzvdcznwgf"), "a write in the worktree is visible from the main checkout" ); } /// Read only to answer "does this look like a Tangled repo?", so the /// duplicate fetch/push lines `remote -v` prints must collapse and an /// absence must be an empty list rather than an error. #[test] fn lists_the_remote_urls_once_each() { let repo = TempRepo::new("remote-urls"); repo.commit("a.txt", "one\n", "Add a");
assert!(remote_urls(here()).is_empty());
add_remote( Path::new("."), "origin", "git@tangled.org:permadeath.com/atgc", ) .unwrap(); assert_eq!( remote_urls(here()), vec!["git@tangled.org:permadeath.com/atgc".to_string()] );
add_remote( Path::new("."), "upstream", "https://github.com/example/thing", ) .unwrap(); let urls = remote_urls(here()); assert_eq!(urls.len(), 2, "{urls:?}"); assert!(urls.iter().any(|u| u.contains("tangled")), "{urls:?}"); }}