//! 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 { 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//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 { 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 { 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 { 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 { 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 { 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 { git_in(dir, &["remote", "-v"]) .map(|out| { let mut urls: Vec = 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 ...`, /// 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:?}"); } }