[{"data":1,"prerenderedAt":1130},["ShallowReactive",2],{"NoscriptNav_XrRK2e2e8meJ0jKVGkb5ULGQDVi3UiFQ9nupAr7Yns":3,"\u002Freports\u002Fextending-git-functionality":8},["Island",4],{"key":5,"result":6},"NoscriptNav_XrRK2e2e8meJ0jKVGkb5ULGQDVi3UiFQ9nupAr7Yns",{"head":7},{},{"id":9,"title":10,"authors":11,"body":13,"canonicalUrl":1117,"canonicalWebsiteName":1118,"category":1119,"date":1120,"description":1121,"extension":1122,"featured":1123,"fullWidthLayout":1123,"image":1124,"imageAlt":1124,"location":1124,"meta":1125,"metaImage":1124,"navigation":751,"path":1126,"seo":1127,"stem":1128,"venue":1124,"venueUrl":1124,"__hash__":1129},"reports\u002Freports\u002Fextending-git-functionality.md","Extending Git Functionality",[12],"andrew",{"type":14,"value":15,"toc":1104},"minimark",[16,20,67,71,87,125,176,179,220,229,233,236,244,255,265,273,279,282,296,299,308,311,314,346,349,387,390,409,422,438,441,444,450,453,456,471,486,490,500,520,523,526,543,552,556,559,565,568,577,581,587,606,627,643,655,675,683,703,725,806,809,820,835,839,842,904,913,917,920,930,963,974,988,996,1002,1005,1009,1021,1029,1037,1048,1056,1064,1068,1071,1074,1077,1083,1086,1100],[17,18,19],"p",{},"I've been researching how to extend git for a project I'm working on. There are seven distinct patterns that I've seen people use to add functionality to git without modifying git itself:",[21,22,23,31,37,43,49,55,61],"ul",{},[24,25,26,30],"li",{},[27,28,29],"strong",{},"Subcommands"," for adding new commands",[24,32,33,36],{},[27,34,35],{},"Clean\u002Fsmudge filters"," for transforming file content",[24,38,39,42],{},[27,40,41],{},"Hooks"," for enforcing workflows",[24,44,45,48],{},[27,46,47],{},"Merge\u002Fdiff drivers"," for custom merging of specific file types",[24,50,51,54],{},[27,52,53],{},"Remote helpers"," for non-git backends",[24,56,57,60],{},[27,58,59],{},"Credential helpers"," for custom auth",[24,62,63,66],{},[27,64,65],{},"Custom ref namespaces"," for storing metadata that syncs with the repo",[68,69,29],"h2",{"id":70},"subcommands",[17,72,73,74,78,79,82,83,86],{},"Put an executable called ",[75,76,77],"code",{},"git-foo"," anywhere in your ",[75,80,81],{},"$PATH"," and git will run it when you type ",[75,84,85],{},"git foo",". That's it. No registration, no configuration. Git literally just looks for executables matching the pattern.",[17,88,89,90,97,98,97,103,97,108,97,113,118,119,124],{},"This is how most git extensions work: ",[91,92,96],"a",{"href":93,"rel":94},"https:\u002F\u002Fgithub.com\u002Fgit-lfs\u002Fgit-lfs",[95],"nofollow","git-lfs",", ",[91,99,102],{"href":100,"rel":101},"https:\u002F\u002Fgithub.com\u002Fnvie\u002Fgitflow",[95],"git-flow",[91,104,107],{"href":105,"rel":106},"https:\u002F\u002Fgithub.com\u002Ftj\u002Fgit-extras",[95],"git-extras",[91,109,112],{"href":110,"rel":111},"https:\u002F\u002Fgithub.com\u002Fmislav\u002Fhub",[95],"hub",[91,114,117],{"href":115,"rel":116},"https:\u002F\u002Fgithub.com\u002Fcli\u002Fcli",[95],"gh",". The ",[91,120,123],{"href":121,"rel":122},"https:\u002F\u002Fgithub.com\u002Fstevemao\u002Fawesome-git-addons",[95],"awesome-git-addons"," list has hundreds of examples.",[126,127,132],"pre",{"className":128,"code":129,"language":130,"meta":131,"style":131},"language-bash shiki shiki-themes github-light github-dark","#!\u002Fbin\u002Fbash\n# Usage: git hierarchize\n# Install: brew install git-hierarchize (or put this script in $PATH)\ngit log --graph --oneline --all\n","bash","",[75,133,134,143,149,155],{"__ignoreMap":131},[135,136,139],"span",{"class":137,"line":138},"line",1,[135,140,142],{"class":141},"sJ8bj","#!\u002Fbin\u002Fbash\n",[135,144,146],{"class":137,"line":145},2,[135,147,148],{"class":141},"# Usage: git hierarchize\n",[135,150,152],{"class":137,"line":151},3,[135,153,154],{"class":141},"# Install: brew install git-hierarchize (or put this script in $PATH)\n",[135,156,158,162,166,170,173],{"class":137,"line":157},4,[135,159,161],{"class":160},"sScJk","git",[135,163,165],{"class":164},"sZZnC"," log",[135,167,169],{"class":168},"sj4cs"," --graph",[135,171,172],{"class":168}," --oneline",[135,174,175],{"class":168}," --all\n",[17,177,178],{},"The pattern is good for:",[21,180,181,188,199,206],{},[24,182,183,184,187],{},"New workflows (",[91,185,102],{"href":100,"rel":186},[95],"'s branching model)",[24,189,190,191,194,195,198],{},"Integrations with external services (",[91,192,112],{"href":110,"rel":193},[95],"\u002F",[91,196,117],{"href":115,"rel":197},[95]," for GitHub)",[24,200,201,202,205],{},"Convenience wrappers (",[91,203,107],{"href":105,"rel":204},[95],"' grab-bag of utilities)",[24,207,208,209,97,214,219],{},"Repository inspection tools (",[91,210,213],{"href":211,"rel":212},"https:\u002F\u002Fgithub.com\u002FIonicaBizau\u002Fgit-stats",[95],"git-stats",[91,215,218],{"href":216,"rel":217},"https:\u002F\u002Fgithub.com\u002Fkamranahmedse\u002Fgit-standup",[95],"git-standup",")",[17,221,222,223,228],{},"Limitations: You're just adding commands. You can't intercept existing git operations, transform content, or change how git talks to remotes. See the ",[91,224,227],{"href":225,"rel":226},"https:\u002F\u002Fgit-scm.com\u002Fdocs\u002Fgit#_git_commands",[95],"git docs"," for more on how git finds commands.",[68,230,232],{"id":231},"cleansmudge-filters","Clean\u002FSmudge Filters",[17,234,235],{},"Filters transform file content on checkout (smudge) and commit (clean). Git pipes the file through your program and stores whatever comes out.",[126,237,242],{"className":238,"code":240,"language":241},[239],"language-text","# .gitattributes\n*.secret filter=vault\n\n# .git\u002Fconfig or ~\u002F.gitconfig\n[filter \"vault\"]\n    clean = gpg --encrypt --recipient you@example.com\n    smudge = gpg --decrypt\n","text",[75,243,240],{"__ignoreMap":131},[17,245,246,247,250,251,254],{},"The clean filter runs when you ",[75,248,249],{},"git add",", the smudge filter runs when you ",[75,252,253],{},"git checkout",", and the repository stores whatever the clean filter outputs.",[17,256,257,264],{},[27,258,259],{},[91,260,263],{"href":261,"rel":262},"https:\u002F\u002Fgithub.com\u002FAGWA\u002Fgit-crypt",[95],"git-crypt"," uses this pattern. Your working directory has plaintext files; the repository stores encrypted blobs. Anyone without the key sees garbage, anyone with the key sees the files transparently.",[17,266,267,272],{},[27,268,269],{},[91,270,96],{"href":93,"rel":271},[95]," also uses filters. The clean filter uploads the real file to an LFS server and outputs a small pointer file, the smudge filter downloads the real content, and the repository only stores pointers.",[126,274,277],{"className":275,"code":276,"language":241},[239],"# What git-lfs stores in the repo (the pointer file)\nversion https:\u002F\u002Fgit-lfs.github.com\u002Fspec\u002Fv1\noid sha256:4d7a214614ab2935c943f9e0ff69d22eadbb8f32b1258daaa5e2ca24d17e2393\nsize 12345\n",[75,278,276],{"__ignoreMap":131},[17,280,281],{},"Filters are good for:",[21,283,284,287,290,293],{},[24,285,286],{},"Transparent encryption (git-crypt)",[24,288,289],{},"Large file handling (git-lfs)",[24,291,292],{},"Normalizing content (converting line endings, stripping trailing whitespace)",[24,294,295],{},"Expanding\u002Fcollapsing keywords",[17,297,298],{},"The constraint: filters must be idempotent. Running clean twice should produce the same output as running it once. And smudge(clean(x)) should equal x for anything you want to round-trip.",[17,300,301,302,307],{},"One thing to note: filters don't run until checkout. If someone clones a repo using git-lfs without having git-lfs installed, they get the pointer files, not the actual content. Same with git-crypt: without the key, you get encrypted garbage. There's no way for the filter to bootstrap itself. See the ",[91,303,306],{"href":304,"rel":305},"https:\u002F\u002Fgit-scm.com\u002Fdocs\u002Fgitattributes#_filter",[95],"gitattributes docs"," for the full filter specification.",[68,309,41],{"id":310},"hooks",[17,312,313],{},"Hooks are scripts that git runs at specific points: before commit, after merge, before push, on the server when receiving a push. There are about 25 different hook points.",[126,315,317],{"className":128,"code":316,"language":130,"meta":131,"style":131},"# .git\u002Fhooks\u002Fpre-commit\n#!\u002Fbin\u002Fbash\nnpm test || exit 1\n",[75,318,319,324,328],{"__ignoreMap":131},[135,320,321],{"class":137,"line":138},[135,322,323],{"class":141},"# .git\u002Fhooks\u002Fpre-commit\n",[135,325,326],{"class":137,"line":145},[135,327,142],{"class":141},[135,329,330,333,336,340,343],{"class":137,"line":151},[135,331,332],{"class":160},"npm",[135,334,335],{"class":164}," test",[135,337,339],{"class":338},"szBVR"," ||",[135,341,342],{"class":168}," exit",[135,344,345],{"class":168}," 1\n",[17,347,348],{},"The hooks most people use:",[21,350,351,357,363,369,375,381],{},[24,352,353,356],{},[27,354,355],{},"pre-commit",": Run linters, formatters, tests before allowing a commit",[24,358,359,362],{},[27,360,361],{},"prepare-commit-msg",": Modify the commit message template",[24,364,365,368],{},[27,366,367],{},"commit-msg",": Validate commit message format",[24,370,371,374],{},[27,372,373],{},"pre-push",": Run tests before pushing",[24,376,377,380],{},[27,378,379],{},"post-checkout",": Update dependencies after switching branches",[24,382,383,386],{},[27,384,385],{},"pre-receive"," (server): Enforce policies on what can be pushed",[17,388,389],{},"Hooks are good for enforcing local workflow (pre-commit linting) and server-side policies (pre-receive rejecting force pushes to main).",[17,391,392,393,97,398,403,404,408],{},"One limitation: hooks aren't versioned with the repository. Each developer has to install them locally. Tools like ",[91,394,397],{"href":395,"rel":396},"https:\u002F\u002Fgithub.com\u002Ftypicode\u002Fhusky",[95],"husky",[91,399,402],{"href":400,"rel":401},"https:\u002F\u002Fgithub.com\u002Fevilmartians\u002Flefthook",[95],"lefthook",", and ",[91,405,355],{"href":406,"rel":407},"https:\u002F\u002Fgithub.com\u002Fpre-commit\u002Fpre-commit",[95]," exist specifically to solve this by providing a way to declare hooks in config files that do get committed.",[17,410,411,414,415,418,419,421],{},[75,412,413],{},"core.hooksPath"," or ",[75,416,417],{},"init.templateDir"," can configure global hooks that apply to every repo. And ",[75,420,379],{}," fires after clone completes, so a global post-checkout hook can bootstrap dependencies automatically.",[17,423,424,431,432,437],{},[27,425,426],{},[91,427,430],{"href":428,"rel":429},"https:\u002F\u002Fgithub.com\u002Farxanas\u002Fgit-branchless",[95],"git-branchless"," uses hooks heavily. It installs a post-commit hook that records every commit you make, enabling features like undo and automatic rebasing. The hook-based approach means it can observe git operations without replacing git commands. The ",[91,433,436],{"href":434,"rel":435},"https:\u002F\u002Fgit-scm.com\u002Fdocs\u002Fgithooks",[95],"githooks docs"," list all available hooks and when they fire.",[68,439,47],{"id":440},"mergediff-drivers",[17,442,443],{},"You can tell git how to merge or diff specific file types.",[126,445,448],{"className":446,"code":447,"language":241},[239],"# .gitattributes\n*.json merge=json-merge\n*.png diff=exif\n\n# .git\u002Fconfig\n[merge \"json-merge\"]\n    driver = json-merge %O %A %B\n\n[diff \"exif\"]\n    textconv = exif\n",[75,449,447],{"__ignoreMap":131},[17,451,452],{},"Merge drivers receive three files (ancestor, ours, theirs) and produce the merged result. Diff drivers can convert binary files to text for diffing.",[17,454,455],{},"This is useful for:",[21,457,458,461,464],{},[24,459,460],{},"Smarter merging of structured formats (JSON, XML, config files)",[24,462,463],{},"Making binary files diffable (images via exif data, PDFs via text extraction)",[24,465,466,467,470],{},"Lock files that shouldn't merge (use the ",[75,468,469],{},"binary"," merge driver)",[17,472,473,474,479,480,485],{},"Most people don't need custom merge drivers. The built-in 3-way merge handles code well. But if you're constantly resolving the same conflicts in generated files, a custom driver might help. See the gitattributes docs for ",[91,475,478],{"href":476,"rel":477},"https:\u002F\u002Fgit-scm.com\u002Fdocs\u002Fgitattributes#_defining_a_custom_merge_driver",[95],"custom merge drivers"," and ",[91,481,484],{"href":482,"rel":483},"https:\u002F\u002Fgit-scm.com\u002Fdocs\u002Fgitattributes#_defining_a_custom_diff_driver",[95],"custom diff drivers",".",[68,487,489],{"id":488},"remote-helpers","Remote Helpers",[17,491,492,493,496,497,485],{},"If you want git to talk to something that isn't a git server, you write a remote helper. Name it ",[75,494,495],{},"git-remote-foo"," and git will invoke it for URLs like ",[75,498,499],{},"foo::some-address",[126,501,503],{"className":128,"code":502,"language":130,"meta":131,"style":131},"# git clone foo::some-address invokes:\ngit-remote-foo origin some-address\n",[75,504,505,510],{"__ignoreMap":131},[135,506,507],{"class":137,"line":138},[135,508,509],{"class":141},"# git clone foo::some-address invokes:\n",[135,511,512,514,517],{"class":137,"line":145},[135,513,495],{"class":160},[135,515,516],{"class":164}," origin",[135,518,519],{"class":164}," some-address\n",[17,521,522],{},"The helper communicates with git over stdin\u002Fstdout using a line-based protocol. It declares capabilities (fetch, push, import, export) and handles the corresponding operations.",[17,524,525],{},"This is how git talks to non-git systems:",[21,527,528,534,540],{},[24,529,530,533],{},[75,531,532],{},"git-remote-hg"," for Mercurial repos",[24,535,536,539],{},[75,537,538],{},"git-remote-svn"," wraps subversion",[24,541,542],{},"Various cloud storage backends (S3, GCS)",[17,544,545,546,551],{},"Remote helpers are the most complex extension point. You're implementing a protocol, handling refs, transferring objects. The ",[91,547,550],{"href":548,"rel":549},"https:\u002F\u002Fgit-scm.com\u002Fdocs\u002Fgitremote-helpers",[95],"gitremote-helpers docs"," describe the protocol, but it's dense. Most people end up reading existing helpers as the de facto spec. Still, they're the only way to make git work with foreign systems transparently.",[68,553,555],{"id":554},"credential-helpers","Credential Helpers",[17,557,558],{},"When git needs authentication, it asks a credential helper. Helpers are simpler than remote helpers, they just store and retrieve usernames and passwords.",[126,560,563],{"className":561,"code":562,"language":241},[239],"# .gitconfig\n[credential]\n    helper = osxkeychain\n",[75,564,562],{"__ignoreMap":131},[17,566,567],{},"Git ships with helpers for OS keychains. The protocol is straightforward: git sends key-value pairs describing what it needs, the helper responds with credentials.",[17,569,570,571,576],{},"You'd write a custom helper to integrate with a secrets manager (Vault, 1Password) or custom authentication system. The ",[91,572,575],{"href":573,"rel":574},"https:\u002F\u002Fgit-scm.com\u002Fdocs\u002Fgitcredentials",[95],"gitcredentials docs"," cover the protocol and available helpers.",[68,578,580],{"id":579},"custom-ref-namespaces","Custom Ref Namespaces",[17,582,583,584,485],{},"Git refs are just pointers to commits, but they're also a distributed key-value store. Create a ref, push it, and every clone gets a copy. Forges and tools exploit this by carving out their own namespaces under ",[75,585,586],{},"refs\u002F",[17,588,589,590,593,594,597,598,601,602,605],{},"GitHub stores pull requests at ",[75,591,592],{},"refs\u002Fpull\u002F\u003Cid>\u002Fhead"," (the PR branch tip) and ",[75,595,596],{},"refs\u002Fpull\u002F\u003Cid>\u002Fmerge"," (the test merge result). These are read-only synthetic refs that persist even after PRs close. GitLab does similar with ",[75,599,600],{},"refs\u002Fmerge-requests\u002F\u003Cid>\u002Fhead",", plus ",[75,603,604],{},"refs\u002Fkeep-around\u002F\u003Csha>"," to prevent garbage collection of commits that have CI pipelines or comments attached.",[17,607,608,609,614,615,618,619,622,623,626],{},"Gerrit takes this furthest with ",[91,610,613],{"href":611,"rel":612},"https:\u002F\u002Fgerrit-review.googlesource.com\u002FDocumentation\u002Fnote-db.html",[95],"NoteDb",". Change metadata lives at ",[75,616,617],{},"refs\u002Fchanges\u002FYZ\u002FXYZ\u002Fmeta"," as a commit graph where each commit records a modification to the code review. Project configuration sits at ",[75,620,621],{},"refs\u002Fmeta\u002Fconfig",". User preferences at ",[75,624,625],{},"refs\u002Fusers\u002Fnn\u002Faccountid",". The entire code review workflow is stored in git, with the SQL database eliminated entirely since Gerrit 3.0.",[17,628,629,634,635,638,639,642],{},[91,630,633],{"href":631,"rel":632},"https:\u002F\u002Fgithub.com\u002Fgittuf\u002Fgittuf",[95],"gittuf"," stores security metadata the same way. The Reference State Log at ",[75,636,637],{},"refs\u002Fgittuf\u002Freference-state-log"," is a hash chain of signed entries recording every repository state change. Policy rules live at ",[75,640,641],{},"refs\u002Fgittuf\u002Fpolicy",". Because it's just refs, gittuf works with any git server without modification.",[17,644,645,650,651,654],{},[91,646,649],{"href":647,"rel":648},"https:\u002F\u002Fgithub.com\u002Fjj-vcs\u002Fjj",[95],"Jujutsu"," stores refs at ",[75,652,653],{},"refs\u002Fjj\u002Fkeep\u002F"," to prevent garbage collection of commits tracked in its operation log.",[17,656,657,658,661,662,665,666,669,670,485],{},"Git itself uses this pattern internally: ",[75,659,660],{},"refs\u002Fnotes\u002F"," for annotations attached to commits without modifying them, ",[75,663,664],{},"refs\u002Fstash"," for stashed changes, and ",[75,667,668],{},"refs\u002Fbisect\u002F"," for bisect state. The git project uses notes to ",[91,671,674],{"href":672,"rel":673},"https:\u002F\u002Fgithub.com\u002Fgit\u002Fgit",[95],"link each commit to its mailing list discussion",[17,676,677,682],{},[91,678,681],{"href":679,"rel":680},"https:\u002F\u002Fgithub.com\u002Fgoogle\u002Fgit-appraise",[95],"git-appraise"," from Google built code review entirely on notes, but the project is now abandoned.",[17,684,685,690,691,694,695,698,699,702],{},[91,686,689],{"href":687,"rel":688},"https:\u002F\u002Fradicle.xyz",[95],"Radicle"," builds a peer-to-peer forge on custom refs. Repository identity lives at ",[75,692,693],{},"refs\u002Frad\u002Fid",", signed refs at ",[75,696,697],{},"refs\u002Frad\u002Fsigrefs",", and collaborative objects like issues and patches under ",[75,700,701],{},"refs\u002Fcobs\u002F",". Each peer's data is namespaced by their node ID, sharing a single object database.",[17,704,705,710,711,714,715,720,721,724],{},[91,706,709],{"href":707,"rel":708},"https:\u002F\u002Fgraphite.dev",[95],"Graphite"," stores branch metadata as JSON blobs under ",[75,712,713],{},"refs\u002Fbranch-metadata\u002F",". ",[91,716,719],{"href":717,"rel":718},"https:\u002F\u002Fdvc.org",[95],"DVC"," tracks ML experiments at ",[75,722,723],{},"refs\u002Fexps\u002F",", keeping thousands of experiment commits local until explicitly shared.",[126,726,728],{"className":128,"code":727,"language":130,"meta":131,"style":131},"# Fetch GitHub PR refs\ngit fetch origin '+refs\u002Fpull\u002F*:refs\u002Fpull\u002F*'\n\n# Fetch Gerrit change metadata\ngit fetch origin refs\u002Fchanges\u002F70\u002F98070\u002Fmeta\ngit log -p FETCH_HEAD\n\n# Fetch git notes\ngit fetch origin 'refs\u002Fnotes\u002F*:refs\u002Fnotes\u002F*'\n",[75,729,730,735,747,753,758,770,783,788,794],{"__ignoreMap":131},[135,731,732],{"class":137,"line":138},[135,733,734],{"class":141},"# Fetch GitHub PR refs\n",[135,736,737,739,742,744],{"class":137,"line":145},[135,738,161],{"class":160},[135,740,741],{"class":164}," fetch",[135,743,516],{"class":164},[135,745,746],{"class":164}," '+refs\u002Fpull\u002F*:refs\u002Fpull\u002F*'\n",[135,748,749],{"class":137,"line":151},[135,750,752],{"emptyLinePlaceholder":751},true,"\n",[135,754,755],{"class":137,"line":157},[135,756,757],{"class":141},"# Fetch Gerrit change metadata\n",[135,759,761,763,765,767],{"class":137,"line":760},5,[135,762,161],{"class":160},[135,764,741],{"class":164},[135,766,516],{"class":164},[135,768,769],{"class":164}," refs\u002Fchanges\u002F70\u002F98070\u002Fmeta\n",[135,771,773,775,777,780],{"class":137,"line":772},6,[135,774,161],{"class":160},[135,776,165],{"class":164},[135,778,779],{"class":168}," -p",[135,781,782],{"class":164}," FETCH_HEAD\n",[135,784,786],{"class":137,"line":785},7,[135,787,752],{"emptyLinePlaceholder":751},[135,789,791],{"class":137,"line":790},8,[135,792,793],{"class":141},"# Fetch git notes\n",[135,795,797,799,801,803],{"class":137,"line":796},9,[135,798,161],{"class":160},[135,800,741],{"class":164},[135,802,516],{"class":164},[135,804,805],{"class":164}," 'refs\u002Fnotes\u002F*:refs\u002Fnotes\u002F*'\n",[17,807,808],{},"Custom refs are good for:",[21,810,811,814,817],{},[24,812,813],{},"Metadata that should travel with clones (security policies, review state)",[24,815,816],{},"Data that benefits from git's deduplication and history",[24,818,819],{},"Avoiding external databases while keeping data distributed",[17,821,822,823,828,829,834],{},"The constraints: refs are public to anyone who can fetch, and the ",[91,824,827],{"href":825,"rel":826},"https:\u002F\u002Fgit-scm.com\u002Fdocs\u002Fgitnamespaces",[95],"gitnamespaces docs"," note that namespaces don't provide access control. If you need private metadata, store it elsewhere. Forges will store and sync custom refs, but they won't display them in the web UI. GitHub ",[91,830,833],{"href":831,"rel":832},"https:\u002F\u002Fgithub.blog\u002F2010-08-25-git-notes-display\u002F",[95],"added notes display in 2010"," then quietly dropped it in 2014 without explanation. Your users need tooling installed locally to see or interact with ref-based data.",[68,836,838],{"id":837},"what-language","What Language?",[17,840,841],{},"Git doesn't care. Here's what existing projects use:",[21,843,844,856,870,878,886,894],{},[24,845,846,849,850,97,853],{},[27,847,848],{},"Shell",": ",[91,851,107],{"href":105,"rel":852},[95],[91,854,102],{"href":100,"rel":855},[95],[24,857,858,849,861,97,864,97,867],{},[27,859,860],{},"Go",[91,862,96],{"href":93,"rel":863},[95],[91,865,117],{"href":115,"rel":866},[95],[91,868,112],{"href":110,"rel":869},[95],[24,871,872,849,875],{},[27,873,874],{},"Rust",[91,876,430],{"href":428,"rel":877},[95],[24,879,880,849,883],{},[27,881,882],{},"C++",[91,884,263],{"href":261,"rel":885},[95],[24,887,888,849,891],{},[27,889,890],{},"Python",[91,892,355],{"href":406,"rel":893},[95],[24,895,896,849,899],{},[27,897,898],{},"Ruby",[91,900,903],{"href":901,"rel":902},"https:\u002F\u002Fgithub.com\u002Fsds\u002Fovercommit",[95],"overcommit",[17,905,906,907,912],{},"For filters specifically, startup time matters because they run once per file. Git does support ",[91,908,911],{"href":909,"rel":910},"https:\u002F\u002Fgit-scm.com\u002Fdocs\u002Fgitattributes#_long_running_filter_process",[95],"long-running filter processes"," that stay alive across multiple files (git-lfs uses this), but you have to implement the protocol.",[68,914,916],{"id":915},"configuration","Configuration",[17,918,919],{},"Extensions need somewhere to store their settings. A few patterns:",[17,921,922,925,926,929],{},[27,923,924],{},"Git config"," is the natural choice. Your extension can use ",[75,927,928],{},"git config"," to read\u002Fwrite values under its own namespace:",[126,931,933],{"className":128,"code":932,"language":130,"meta":131,"style":131},"git config --global lfs.fetchrecentalways true\ngit config myextension.somesetting value\n",[75,934,935,951],{"__ignoreMap":131},[135,936,937,939,942,945,948],{"class":137,"line":138},[135,938,161],{"class":160},[135,940,941],{"class":164}," config",[135,943,944],{"class":168}," --global",[135,946,947],{"class":164}," lfs.fetchrecentalways",[135,949,950],{"class":168}," true\n",[135,952,953,955,957,960],{"class":137,"line":145},[135,954,161],{"class":160},[135,956,941],{"class":164},[135,958,959],{"class":164}," myextension.somesetting",[135,961,962],{"class":164}," value\n",[17,964,965,966,969,970,973],{},"Config lives in ",[75,967,968],{},"~\u002F.gitconfig"," (global) or ",[75,971,972],{},".git\u002Fconfig"," (per-repo). Users already know how to edit these.",[17,975,976,979,980,983,984,987],{},[27,977,978],{},"Dedicated dotfiles"," work when you need more structure. git-lfs uses ",[75,981,982],{},".lfsconfig",", git-crypt stores keys in ",[75,985,986],{},".git-crypt\u002F",". These can be committed to the repo so settings travel with it.",[17,989,990,995],{},[27,991,992],{},[75,993,994],{},".gitattributes"," declares which files use filters or drivers:",[126,997,1000],{"className":998,"code":999,"language":241},[239],"*.psd filter=lfs diff=lfs merge=lfs\n*.secret filter=git-crypt diff=git-crypt\n",[75,1001,999],{"__ignoreMap":131},[17,1003,1004],{},"This file should be committed, it's how the repo tells git which extensions to invoke for which paths.",[68,1006,1008],{"id":1007},"interesting-examples","Interesting Examples",[17,1010,1011,1016,1017,1020],{},[27,1012,1013],{},[91,1014,96],{"href":93,"rel":1015},[95],": Combines multiple patterns. It's a subcommand (",[75,1018,1019],{},"git lfs track","), uses clean\u002Fsmudge filters for the actual file handling, and hooks into pre-push to upload files.",[17,1022,1023,1028],{},[27,1024,1025],{},[91,1026,263],{"href":261,"rel":1027},[95],": Clean\u002Fsmudge filters with a subcommand for key management. The C++ implementation keeps the filter fast.",[17,1030,1031,1036],{},[27,1032,1033],{},[91,1034,430],{"href":428,"rel":1035},[95],": Hook-based observation combined with subcommands for the UI. Shows how to build features on top of git without modifying git itself. The Rust implementation handles large repos well.",[17,1038,1039,1047],{},[27,1040,1041,194,1044],{},[91,1042,112],{"href":110,"rel":1043},[95],[91,1045,117],{"href":115,"rel":1046},[95],": Pure subcommand pattern. Wraps git commands and adds GitHub-specific features. Shows how far you can get with just new commands.",[17,1049,1050,1055],{},[27,1051,1052],{},[91,1053,903],{"href":901,"rel":1054},[95],": Hook manager in Ruby. Lets you configure hooks via YAML and provides a library of built-in checks for linting, security, and commit message formatting.",[17,1057,1058,1063],{},[27,1059,1060],{},[91,1061,633],{"href":631,"rel":1062},[95],": Uses custom refs to store a cryptographically signed log of all repository state changes. Subcommands for policy management. The ref-based approach means it works with any git server without modification.",[68,1065,1067],{"id":1066},"installation-required","Installation Required",[17,1069,1070],{},"Filters and hooks work transparently, users run normal git commands and your extension does its work, but your extension has to be installed first.",[17,1072,1073],{},"If someone clones a repo that uses git-lfs without having git-lfs installed, they don't get an error, they get pointer files instead of content. Git has no mechanism to say \"this repo requires extension X\".",[17,1075,1076],{},"The best you can do is document requirements, fail loudly when things are wrong, and make installation easy. git-lfs handles this reasonably well, if you try to push without it installed you get an error pointing you to the fix:",[126,1078,1081],{"className":1079,"code":1080,"language":241},[239],"$ brew install git-lfs && git lfs install  # macOS\n$ apt install git-lfs && git lfs install   # Debian\u002FUbuntu\n$ dnf install git-lfs && git lfs install   # Fedora\n",[75,1082,1080],{"__ignoreMap":131},[1084,1085],"hr",{},[17,1087,1088,1089,1094,1095,485],{},"If you know of other git extension techniques or projects worth mentioning or have corrections, reach out on ",[91,1090,1093],{"href":1091,"rel":1092},"https:\u002F\u002Fmastodon.social\u002F@andrewnez",[95],"Mastodon"," or submit a pull request on ",[91,1096,1099],{"href":1097,"rel":1098},"https:\u002F\u002Fgithub.com\u002Fandrew\u002Fnesbitt.io\u002Fblob\u002Fmaster\u002F_posts\u002F2025-11-26-extending-git-functionality.md",[95],"GitHub",[1101,1102,1103],"style",{},"html pre.shiki code .sJ8bj, html code.shiki .sJ8bj{--shiki-default:#6A737D;--shiki-dark:#6A737D}html pre.shiki code .sScJk, html code.shiki .sScJk{--shiki-default:#6F42C1;--shiki-dark:#B392F0}html pre.shiki code .sZZnC, html code.shiki .sZZnC{--shiki-default:#032F62;--shiki-dark:#9ECBFF}html pre.shiki code .sj4cs, html code.shiki .sj4cs{--shiki-default:#005CC5;--shiki-dark:#79B8FF}html .default .shiki span {color: var(--shiki-default);background: var(--shiki-default-bg);font-style: var(--shiki-default-font-style);font-weight: var(--shiki-default-font-weight);text-decoration: var(--shiki-default-text-decoration);}html .shiki span {color: var(--shiki-default);background: var(--shiki-default-bg);font-style: var(--shiki-default-font-style);font-weight: var(--shiki-default-font-weight);text-decoration: var(--shiki-default-text-decoration);}html .dark .shiki span {color: var(--shiki-dark);background: var(--shiki-dark-bg);font-style: var(--shiki-dark-font-style);font-weight: var(--shiki-dark-font-weight);text-decoration: var(--shiki-dark-text-decoration);}html.dark .shiki span {color: var(--shiki-dark);background: var(--shiki-dark-bg);font-style: var(--shiki-dark-font-style);font-weight: var(--shiki-dark-font-weight);text-decoration: var(--shiki-dark-text-decoration);}html pre.shiki code .szBVR, html code.shiki .szBVR{--shiki-default:#D73A49;--shiki-dark:#F97583}",{"title":131,"searchDepth":145,"depth":145,"links":1105},[1106,1107,1108,1109,1110,1111,1112,1113,1114,1115,1116],{"id":70,"depth":145,"text":29},{"id":231,"depth":145,"text":232},{"id":310,"depth":145,"text":41},{"id":440,"depth":145,"text":47},{"id":488,"depth":145,"text":489},{"id":554,"depth":145,"text":555},{"id":579,"depth":145,"text":580},{"id":837,"depth":145,"text":838},{"id":915,"depth":145,"text":916},{"id":1007,"depth":145,"text":1008},{"id":1066,"depth":145,"text":1067},"https:\u002F\u002Fnesbitt.io\u002F2025\u002F11\u002F26\u002Fextending-git-functionality","nesbitt.io","tooling","2025-11-26","A practical guide to the different ways you can extend git: subcommands, filters, hooks, remote helpers, and more.","md",false,null,{},"\u002Freports\u002Fextending-git-functionality",{"title":10,"description":1121},"reports\u002Fextending-git-functionality","a53HJ2fhwbwUsTVfr1qqC_CS9A_HZ56tmWFOIWVo_oY",1780596103423]