Implement basic version control system (VCS) with init, add, commit, log, status, and cat-file commands
- Added Cargo.toml for project configuration and dependencies. - Created main.rs as the entry point for the VCS CLI application. - Implemented command structure using Clap for parsing CLI arguments. - Developed repository initialization and object storage functionality. - Implemented staging of files and committing changes with message. - Added functionality to view commit history and status of the working directory. - Implemented object model for blobs, trees, and commits with serialization and deserialization. - Created a low-level object store for reading and writing compressed object files. - Implemented tree building and flattening for managing file structure in commits.
This commit is contained in:
commit
e0b6541e5b
7 changed files with 1430 additions and 0 deletions
1
.gitignore
vendored
Normal file
1
.gitignore
vendored
Normal file
|
|
@ -0,0 +1 @@
|
|||
/target
|
||||
541
Cargo.lock
generated
Normal file
541
Cargo.lock
generated
Normal file
|
|
@ -0,0 +1,541 @@
|
|||
# This file is automatically @generated by Cargo.
|
||||
# It is not intended for manual editing.
|
||||
version = 4
|
||||
|
||||
[[package]]
|
||||
name = "adler2"
|
||||
version = "2.0.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "320119579fcad9c21884f5c4861d16174d0e06250625266f50fe6898340abefa"
|
||||
|
||||
[[package]]
|
||||
name = "android_system_properties"
|
||||
version = "0.1.5"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "819e7219dbd41043ac279b19830f2efc897156490d7fd6ea916720117ee66311"
|
||||
dependencies = [
|
||||
"libc",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "anstream"
|
||||
version = "1.0.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "824a212faf96e9acacdbd09febd34438f8f711fb84e09a8916013cd7815ca28d"
|
||||
dependencies = [
|
||||
"anstyle",
|
||||
"anstyle-parse",
|
||||
"anstyle-query",
|
||||
"anstyle-wincon",
|
||||
"colorchoice",
|
||||
"is_terminal_polyfill",
|
||||
"utf8parse",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "anstyle"
|
||||
version = "1.0.14"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "940b3a0ca603d1eade50a4846a2afffd5ef57a9feac2c0e2ec2e14f9ead76000"
|
||||
|
||||
[[package]]
|
||||
name = "anstyle-parse"
|
||||
version = "1.0.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "52ce7f38b242319f7cabaa6813055467063ecdc9d355bbb4ce0c68908cd8130e"
|
||||
dependencies = [
|
||||
"utf8parse",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "anstyle-query"
|
||||
version = "1.1.5"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "40c48f72fd53cd289104fc64099abca73db4166ad86ea0b4341abe65af83dadc"
|
||||
dependencies = [
|
||||
"windows-sys",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "anstyle-wincon"
|
||||
version = "3.0.11"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "291e6a250ff86cd4a820112fb8898808a366d8f9f58ce16d1f538353ad55747d"
|
||||
dependencies = [
|
||||
"anstyle",
|
||||
"once_cell_polyfill",
|
||||
"windows-sys",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "autocfg"
|
||||
version = "1.5.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "c08606f8c3cbf4ce6ec8e28fb0014a2c086708fe954eaa885384a6165172e7e8"
|
||||
|
||||
[[package]]
|
||||
name = "block-buffer"
|
||||
version = "0.10.4"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "3078c7629b62d3f0439517fa394996acacc5cbc91c5a20d8c658e77abd503a71"
|
||||
dependencies = [
|
||||
"generic-array",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "bumpalo"
|
||||
version = "3.20.2"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "5d20789868f4b01b2f2caec9f5c4e0213b41e3e5702a50157d699ae31ced2fcb"
|
||||
|
||||
[[package]]
|
||||
name = "cc"
|
||||
version = "1.2.60"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "43c5703da9466b66a946814e1adf53ea2c90f10063b86290cc9eb67ce3478a20"
|
||||
dependencies = [
|
||||
"find-msvc-tools",
|
||||
"shlex",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "cfg-if"
|
||||
version = "1.0.4"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "9330f8b2ff13f34540b44e946ef35111825727b38d33286ef986142615121801"
|
||||
|
||||
[[package]]
|
||||
name = "chrono"
|
||||
version = "0.4.44"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "c673075a2e0e5f4a1dde27ce9dee1ea4558c7ffe648f576438a20ca1d2acc4b0"
|
||||
dependencies = [
|
||||
"iana-time-zone",
|
||||
"js-sys",
|
||||
"num-traits",
|
||||
"wasm-bindgen",
|
||||
"windows-link",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "clap"
|
||||
version = "4.6.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "b193af5b67834b676abd72466a96c1024e6a6ad978a1f484bd90b85c94041351"
|
||||
dependencies = [
|
||||
"clap_builder",
|
||||
"clap_derive",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "clap_builder"
|
||||
version = "4.6.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "714a53001bf66416adb0e2ef5ac857140e7dc3a0c48fb28b2f10762fc4b5069f"
|
||||
dependencies = [
|
||||
"anstream",
|
||||
"anstyle",
|
||||
"clap_lex",
|
||||
"strsim",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "clap_derive"
|
||||
version = "4.6.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "1110bd8a634a1ab8cb04345d8d878267d57c3cf1b38d91b71af6686408bbca6a"
|
||||
dependencies = [
|
||||
"heck",
|
||||
"proc-macro2",
|
||||
"quote",
|
||||
"syn",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "clap_lex"
|
||||
version = "1.1.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "c8d4a3bb8b1e0c1050499d1815f5ab16d04f0959b233085fb31653fbfc9d98f9"
|
||||
|
||||
[[package]]
|
||||
name = "colorchoice"
|
||||
version = "1.0.5"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "1d07550c9036bf2ae0c684c4297d503f838287c83c53686d05370d0e139ae570"
|
||||
|
||||
[[package]]
|
||||
name = "core-foundation-sys"
|
||||
version = "0.8.7"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "773648b94d0e5d620f64f280777445740e61fe701025087ec8b57f45c791888b"
|
||||
|
||||
[[package]]
|
||||
name = "cpufeatures"
|
||||
version = "0.2.17"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "59ed5838eebb26a2bb2e58f6d5b5316989ae9d08bab10e0e6d103e656d1b0280"
|
||||
dependencies = [
|
||||
"libc",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "crc32fast"
|
||||
version = "1.5.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "9481c1c90cbf2ac953f07c8d4a58aa3945c425b7185c9154d67a65e4230da511"
|
||||
dependencies = [
|
||||
"cfg-if",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "crypto-common"
|
||||
version = "0.1.7"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "78c8292055d1c1df0cce5d180393dc8cce0abec0a7102adb6c7b1eef6016d60a"
|
||||
dependencies = [
|
||||
"generic-array",
|
||||
"typenum",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "digest"
|
||||
version = "0.10.7"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "9ed9a281f7bc9b7576e61468ba615a66a5c8cfdff42420a70aa82701a3b1e292"
|
||||
dependencies = [
|
||||
"block-buffer",
|
||||
"crypto-common",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "find-msvc-tools"
|
||||
version = "0.1.9"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "5baebc0774151f905a1a2cc41989300b1e6fbb29aff0ceffa1064fdd3088d582"
|
||||
|
||||
[[package]]
|
||||
name = "flate2"
|
||||
version = "1.1.9"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "843fba2746e448b37e26a819579957415c8cef339bf08564fe8b7ddbd959573c"
|
||||
dependencies = [
|
||||
"crc32fast",
|
||||
"miniz_oxide",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "generic-array"
|
||||
version = "0.14.7"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "85649ca51fd72272d7821adaf274ad91c288277713d9c18820d8499a7ff69e9a"
|
||||
dependencies = [
|
||||
"typenum",
|
||||
"version_check",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "heck"
|
||||
version = "0.5.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "2304e00983f87ffb38b55b444b5e3b60a884b5d30c0fca7d82fe33449bbe55ea"
|
||||
|
||||
[[package]]
|
||||
name = "hex"
|
||||
version = "0.4.3"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "7f24254aa9a54b5c858eaee2f5bccdb46aaf0e486a595ed5fd8f86ba55232a70"
|
||||
|
||||
[[package]]
|
||||
name = "iana-time-zone"
|
||||
version = "0.1.65"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "e31bc9ad994ba00e440a8aa5c9ef0ec67d5cb5e5cb0cc7f8b744a35b389cc470"
|
||||
dependencies = [
|
||||
"android_system_properties",
|
||||
"core-foundation-sys",
|
||||
"iana-time-zone-haiku",
|
||||
"js-sys",
|
||||
"log",
|
||||
"wasm-bindgen",
|
||||
"windows-core",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "iana-time-zone-haiku"
|
||||
version = "0.1.2"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "f31827a206f56af32e590ba56d5d2d085f558508192593743f16b2306495269f"
|
||||
dependencies = [
|
||||
"cc",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "is_terminal_polyfill"
|
||||
version = "1.70.2"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "a6cb138bb79a146c1bd460005623e142ef0181e3d0219cb493e02f7d08a35695"
|
||||
|
||||
[[package]]
|
||||
name = "js-sys"
|
||||
version = "0.3.95"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "2964e92d1d9dc3364cae4d718d93f227e3abb088e747d92e0395bfdedf1c12ca"
|
||||
dependencies = [
|
||||
"once_cell",
|
||||
"wasm-bindgen",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "libc"
|
||||
version = "0.2.184"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "48f5d2a454e16a5ea0f4ced81bd44e4cfc7bd3a507b61887c99fd3538b28e4af"
|
||||
|
||||
[[package]]
|
||||
name = "log"
|
||||
version = "0.4.29"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "5e5032e24019045c762d3c0f28f5b6b8bbf38563a65908389bf7978758920897"
|
||||
|
||||
[[package]]
|
||||
name = "miniz_oxide"
|
||||
version = "0.8.9"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "1fa76a2c86f704bdb222d66965fb3d63269ce38518b83cb0575fca855ebb6316"
|
||||
dependencies = [
|
||||
"adler2",
|
||||
"simd-adler32",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "num-traits"
|
||||
version = "0.2.19"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "071dfc062690e90b734c0b2273ce72ad0ffa95f0c74596bc250dcfd960262841"
|
||||
dependencies = [
|
||||
"autocfg",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "once_cell"
|
||||
version = "1.21.4"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "9f7c3e4beb33f85d45ae3e3a1792185706c8e16d043238c593331cc7cd313b50"
|
||||
|
||||
[[package]]
|
||||
name = "once_cell_polyfill"
|
||||
version = "1.70.2"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "384b8ab6d37215f3c5301a95a4accb5d64aa607f1fcb26a11b5303878451b4fe"
|
||||
|
||||
[[package]]
|
||||
name = "proc-macro2"
|
||||
version = "1.0.106"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "8fd00f0bb2e90d81d1044c2b32617f68fcb9fa3bb7640c23e9c748e53fb30934"
|
||||
dependencies = [
|
||||
"unicode-ident",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "quote"
|
||||
version = "1.0.45"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "41f2619966050689382d2b44f664f4bc593e129785a36d6ee376ddf37259b924"
|
||||
dependencies = [
|
||||
"proc-macro2",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "rustversion"
|
||||
version = "1.0.22"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "b39cdef0fa800fc44525c84ccb54a029961a8215f9619753635a9c0d2538d46d"
|
||||
|
||||
[[package]]
|
||||
name = "sha1"
|
||||
version = "0.10.6"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "e3bf829a2d51ab4a5ddf1352d8470c140cadc8301b2ae1789db023f01cedd6ba"
|
||||
dependencies = [
|
||||
"cfg-if",
|
||||
"cpufeatures",
|
||||
"digest",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "shlex"
|
||||
version = "1.3.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "0fda2ff0d084019ba4d7c6f371c95d8fd75ce3524c3cb8fb653a3023f6323e64"
|
||||
|
||||
[[package]]
|
||||
name = "simd-adler32"
|
||||
version = "0.3.9"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "703d5c7ef118737c72f1af64ad2f6f8c5e1921f818cdcb97b8fe6fc69bf66214"
|
||||
|
||||
[[package]]
|
||||
name = "strsim"
|
||||
version = "0.11.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "7da8b5736845d9f2fcb837ea5d9e2628564b3b043a70948a3f0b778838c5fb4f"
|
||||
|
||||
[[package]]
|
||||
name = "syn"
|
||||
version = "2.0.117"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "e665b8803e7b1d2a727f4023456bbbbe74da67099c585258af0ad9c5013b9b99"
|
||||
dependencies = [
|
||||
"proc-macro2",
|
||||
"quote",
|
||||
"unicode-ident",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "typenum"
|
||||
version = "1.19.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "562d481066bde0658276a35467c4af00bdc6ee726305698a55b86e61d7ad82bb"
|
||||
|
||||
[[package]]
|
||||
name = "unicode-ident"
|
||||
version = "1.0.24"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "e6e4313cd5fcd3dad5cafa179702e2b244f760991f45397d14d4ebf38247da75"
|
||||
|
||||
[[package]]
|
||||
name = "utf8parse"
|
||||
version = "0.2.2"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "06abde3611657adf66d383f00b093d7faecc7fa57071cce2578660c9f1010821"
|
||||
|
||||
[[package]]
|
||||
name = "version"
|
||||
version = "0.1.0"
|
||||
dependencies = [
|
||||
"chrono",
|
||||
"clap",
|
||||
"flate2",
|
||||
"hex",
|
||||
"sha1",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "version_check"
|
||||
version = "0.9.5"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "0b928f33d975fc6ad9f86c8f283853ad26bdd5b10b7f1542aa2fa15e2289105a"
|
||||
|
||||
[[package]]
|
||||
name = "wasm-bindgen"
|
||||
version = "0.2.118"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "0bf938a0bacb0469e83c1e148908bd7d5a6010354cf4fb73279b7447422e3a89"
|
||||
dependencies = [
|
||||
"cfg-if",
|
||||
"once_cell",
|
||||
"rustversion",
|
||||
"wasm-bindgen-macro",
|
||||
"wasm-bindgen-shared",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "wasm-bindgen-macro"
|
||||
version = "0.2.118"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "eeff24f84126c0ec2db7a449f0c2ec963c6a49efe0698c4242929da037ca28ed"
|
||||
dependencies = [
|
||||
"quote",
|
||||
"wasm-bindgen-macro-support",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "wasm-bindgen-macro-support"
|
||||
version = "0.2.118"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "9d08065faf983b2b80a79fd87d8254c409281cf7de75fc4b773019824196c904"
|
||||
dependencies = [
|
||||
"bumpalo",
|
||||
"proc-macro2",
|
||||
"quote",
|
||||
"syn",
|
||||
"wasm-bindgen-shared",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "wasm-bindgen-shared"
|
||||
version = "0.2.118"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "5fd04d9e306f1907bd13c6361b5c6bfc7b3b3c095ed3f8a9246390f8dbdee129"
|
||||
dependencies = [
|
||||
"unicode-ident",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "windows-core"
|
||||
version = "0.62.2"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "b8e83a14d34d0623b51dce9581199302a221863196a1dde71a7663a4c2be9deb"
|
||||
dependencies = [
|
||||
"windows-implement",
|
||||
"windows-interface",
|
||||
"windows-link",
|
||||
"windows-result",
|
||||
"windows-strings",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "windows-implement"
|
||||
version = "0.60.2"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "053e2e040ab57b9dc951b72c264860db7eb3b0200ba345b4e4c3b14f67855ddf"
|
||||
dependencies = [
|
||||
"proc-macro2",
|
||||
"quote",
|
||||
"syn",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "windows-interface"
|
||||
version = "0.59.3"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "3f316c4a2570ba26bbec722032c4099d8c8bc095efccdc15688708623367e358"
|
||||
dependencies = [
|
||||
"proc-macro2",
|
||||
"quote",
|
||||
"syn",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "windows-link"
|
||||
version = "0.2.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "f0805222e57f7521d6a62e36fa9163bc891acd422f971defe97d64e70d0a4fe5"
|
||||
|
||||
[[package]]
|
||||
name = "windows-result"
|
||||
version = "0.4.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "7781fa89eaf60850ac3d2da7af8e5242a5ea78d1a11c49bf2910bb5a73853eb5"
|
||||
dependencies = [
|
||||
"windows-link",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "windows-strings"
|
||||
version = "0.5.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "7837d08f69c77cf6b07689544538e017c1bfcf57e34b4c0ff58e6c2cd3b37091"
|
||||
dependencies = [
|
||||
"windows-link",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "windows-sys"
|
||||
version = "0.61.2"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "ae137229bcbd6cdf0f7b80a31df61766145077ddf49416a728b02cb3921ff3fc"
|
||||
dependencies = [
|
||||
"windows-link",
|
||||
]
|
||||
15
Cargo.toml
Normal file
15
Cargo.toml
Normal file
|
|
@ -0,0 +1,15 @@
|
|||
[package]
|
||||
name = "version"
|
||||
version = "0.1.0"
|
||||
edition = "2024"
|
||||
|
||||
[[bin]]
|
||||
name = "vcs"
|
||||
path = "src/main.rs"
|
||||
|
||||
[dependencies]
|
||||
clap = { version = "4", features = ["derive"] }
|
||||
sha1 = "0.10"
|
||||
flate2 = "1.0"
|
||||
hex = "0.4"
|
||||
chrono = { version = "0.4", features = ["clock"] }
|
||||
365
src/main.rs
Normal file
365
src/main.rs
Normal file
|
|
@ -0,0 +1,365 @@
|
|||
mod objects;
|
||||
mod repo;
|
||||
mod store;
|
||||
|
||||
use chrono::Utc;
|
||||
use clap::{Parser, Subcommand};
|
||||
use objects::{ObjData, Object};
|
||||
use repo::Repo;
|
||||
use std::path::Path;
|
||||
|
||||
// ── CLI ────────────────────────────────────────────────────────────────────
|
||||
|
||||
#[derive(Parser)]
|
||||
#[command(
|
||||
name = "vcs",
|
||||
about = "A mini Git — learn how version control works under the hood"
|
||||
)]
|
||||
struct Cli {
|
||||
#[command(subcommand)]
|
||||
cmd: Cmd,
|
||||
}
|
||||
|
||||
#[derive(Subcommand)]
|
||||
enum Cmd {
|
||||
/// Initialize a new repository in the current directory
|
||||
Init,
|
||||
|
||||
/// Stage files for the next commit (like `git add`)
|
||||
Add { files: Vec<String> },
|
||||
|
||||
/// Record a snapshot of staged changes (like `git commit`)
|
||||
Commit {
|
||||
#[arg(short, long)]
|
||||
message: String,
|
||||
},
|
||||
|
||||
/// Show the commit history (like `git log`)
|
||||
Log,
|
||||
|
||||
/// Show staged and unstaged changes (like `git status`)
|
||||
Status,
|
||||
|
||||
/// Display the raw content of any stored object (like `git cat-file -p`)
|
||||
CatFile {
|
||||
/// Full or abbreviated hash (≥4 chars)
|
||||
hash: String,
|
||||
/// Show object type only
|
||||
#[arg(short = 't')]
|
||||
type_only: bool,
|
||||
},
|
||||
|
||||
/// Hash a file and optionally store it as a blob (like `git hash-object`)
|
||||
HashObject {
|
||||
file: String,
|
||||
/// Write the blob into the object store
|
||||
#[arg(short = 'w')]
|
||||
write: bool,
|
||||
},
|
||||
}
|
||||
|
||||
// ── Entry point ────────────────────────────────────────────────────────────
|
||||
|
||||
fn main() {
|
||||
// On Unix, restore the default SIGPIPE handler so piped commands like
|
||||
// `vcs log | head` exit cleanly instead of panicking with "broken pipe".
|
||||
#[cfg(unix)]
|
||||
unsafe {
|
||||
unsafe extern "C" { fn signal(sig: i32, handler: usize) -> usize; }
|
||||
signal(13 /* SIGPIPE */, 0 /* SIG_DFL */);
|
||||
}
|
||||
|
||||
let cli = Cli::parse();
|
||||
let result = match cli.cmd {
|
||||
Cmd::Init => cmd_init(),
|
||||
Cmd::Add { files } => cmd_add(files),
|
||||
Cmd::Commit { message } => cmd_commit(message),
|
||||
Cmd::Log => cmd_log(),
|
||||
Cmd::Status => cmd_status(),
|
||||
Cmd::CatFile { hash, type_only } => cmd_cat_file(hash, type_only),
|
||||
Cmd::HashObject { file, write } => cmd_hash_object(file, write),
|
||||
};
|
||||
|
||||
if let Err(e) = result {
|
||||
eprintln!("error: {}", e);
|
||||
std::process::exit(1);
|
||||
}
|
||||
}
|
||||
|
||||
// ── Commands ───────────────────────────────────────────────────────────────
|
||||
|
||||
fn cmd_init() -> Result<(), String> {
|
||||
let cwd = std::env::current_dir().map_err(|e| e.to_string())?;
|
||||
Repo::init(&cwd)?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Stage files: hash each one as a blob, write it to the object store,
|
||||
/// then update the index with path → blob-hash.
|
||||
fn cmd_add(files: Vec<String>) -> Result<(), String> {
|
||||
let repo = Repo::find()?;
|
||||
let mut index = repo.read_index()?;
|
||||
|
||||
for file in &files {
|
||||
let path = Path::new(file);
|
||||
if !path.exists() {
|
||||
return Err(format!("'{}': no such file", file));
|
||||
}
|
||||
if path.is_dir() {
|
||||
return Err(format!("'{}': is a directory — add individual files", file));
|
||||
}
|
||||
|
||||
let content = std::fs::read(path).map_err(|e| e.to_string())?;
|
||||
let blob = Object::blob(content);
|
||||
let hash = repo.store.write(&blob)?;
|
||||
|
||||
// Normalise to a repo-relative path
|
||||
let rel = path
|
||||
.strip_prefix(&repo.root)
|
||||
.unwrap_or(path)
|
||||
.to_string_lossy()
|
||||
.to_string();
|
||||
|
||||
println!("staged: {} ({})", rel, &hash[..7]);
|
||||
index.insert(rel, hash);
|
||||
}
|
||||
|
||||
repo.write_index(&index)
|
||||
}
|
||||
|
||||
/// Build a tree from the index, wrap it in a commit, update HEAD.
|
||||
///
|
||||
/// working dir ──add──► index ──commit──► object store (tree + commit)
|
||||
/// ▲
|
||||
/// HEAD / branch ref
|
||||
fn cmd_commit(message: String) -> Result<(), String> {
|
||||
let repo = Repo::find()?;
|
||||
let index = repo.read_index()?;
|
||||
|
||||
if index.is_empty() {
|
||||
return Err("nothing to commit (staging area is empty — run `vcs add <file>`)".into());
|
||||
}
|
||||
|
||||
// 1. Build a tree object from the current index
|
||||
let tree_hash = repo.build_tree(&index)?;
|
||||
|
||||
// 2. Chain to the current HEAD commit (if any)
|
||||
let parents: Vec<String> = repo.head_commit()?.into_iter().collect();
|
||||
|
||||
// 3. Create the commit object
|
||||
let ts = format!("{} +0000", Utc::now().timestamp());
|
||||
let author = format!("User <user@local> {}", ts);
|
||||
let commit = Object::commit(tree_hash, parents, author.clone(), author, message.clone());
|
||||
|
||||
// 4. Store it and advance the branch pointer
|
||||
let commit_hash = repo.store.write(&commit)?;
|
||||
repo.update_head(&commit_hash)?;
|
||||
|
||||
let first_line = message.lines().next().unwrap_or("");
|
||||
println!("[{}] {}", &commit_hash[..7], first_line);
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Walk the commit chain from HEAD backwards, printing each commit.
|
||||
fn cmd_log() -> Result<(), String> {
|
||||
let repo = Repo::find()?;
|
||||
let mut cursor = repo.head_commit()?;
|
||||
|
||||
if cursor.is_none() {
|
||||
println!("(no commits yet)");
|
||||
return Ok(());
|
||||
}
|
||||
|
||||
while let Some(hash) = cursor {
|
||||
let obj = repo.store.read(&hash)?;
|
||||
let ObjData::Commit { tree, parents, author, message, .. } = &obj.data else {
|
||||
return Err(format!("{} is not a commit", &hash[..7]));
|
||||
};
|
||||
|
||||
println!("commit {}", hash);
|
||||
// Show just the name/email part (before the timestamp)
|
||||
let author_short = author.rsplitn(3, ' ').last().unwrap_or(author);
|
||||
println!("Author: {}", author_short);
|
||||
println!("Tree: {}", &tree[..7]);
|
||||
println!();
|
||||
for line in message.lines() {
|
||||
println!(" {}", line);
|
||||
}
|
||||
println!();
|
||||
|
||||
cursor = parents.first().cloned();
|
||||
}
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Compare three layers:
|
||||
/// HEAD tree ←→ index ←→ working directory
|
||||
fn cmd_status() -> Result<(), String> {
|
||||
let repo = Repo::find()?;
|
||||
let index = repo.read_index()?;
|
||||
let head_tree = repo.head_tree()?;
|
||||
|
||||
// ── Staged changes (index vs HEAD) ─────────────────────────────────
|
||||
let mut staged_new: Vec<String> = Vec::new();
|
||||
let mut staged_modified: Vec<String> = Vec::new();
|
||||
let mut staged_deleted: Vec<String> = Vec::new();
|
||||
|
||||
for (path, hash) in &index {
|
||||
match head_tree.get(path) {
|
||||
None => staged_new.push(path.clone()),
|
||||
Some(h) if h != hash => staged_modified.push(path.clone()),
|
||||
_ => {}
|
||||
}
|
||||
}
|
||||
for path in head_tree.keys() {
|
||||
if !index.contains_key(path) {
|
||||
staged_deleted.push(path.clone());
|
||||
}
|
||||
}
|
||||
|
||||
// ── Unstaged changes (working dir vs index) ─────────────────────────
|
||||
let mut unstaged_modified: Vec<String> = Vec::new();
|
||||
let mut unstaged_deleted: Vec<String> = Vec::new();
|
||||
|
||||
for (path, index_hash) in &index {
|
||||
let abs = repo.root.join(path);
|
||||
if !abs.exists() {
|
||||
unstaged_deleted.push(path.clone());
|
||||
} else {
|
||||
let content = std::fs::read(&abs).map_err(|e| e.to_string())?;
|
||||
let disk_hash = Object::blob(content).hash_hex();
|
||||
if disk_hash != *index_hash {
|
||||
unstaged_modified.push(path.clone());
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// ── Untracked files ─────────────────────────────────────────────────
|
||||
let mut untracked: Vec<String> = Vec::new();
|
||||
collect_untracked(&repo.root, &repo.root, &index, &mut untracked)?;
|
||||
|
||||
// ── Print ────────────────────────────────────────────────────────────
|
||||
let anything = !staged_new.is_empty()
|
||||
|| !staged_modified.is_empty()
|
||||
|| !staged_deleted.is_empty()
|
||||
|| !unstaged_modified.is_empty()
|
||||
|| !unstaged_deleted.is_empty();
|
||||
|
||||
if anything {
|
||||
if !staged_new.is_empty() || !staged_modified.is_empty() || !staged_deleted.is_empty() {
|
||||
println!("Changes staged for commit:");
|
||||
for f in &staged_new { println!(" new file: {}", f); }
|
||||
for f in &staged_modified { println!(" modified: {}", f); }
|
||||
for f in &staged_deleted { println!(" deleted: {}", f); }
|
||||
println!();
|
||||
}
|
||||
if !unstaged_modified.is_empty() || !unstaged_deleted.is_empty() {
|
||||
println!("Changes not staged for commit:");
|
||||
for f in &unstaged_modified { println!(" modified: {}", f); }
|
||||
for f in &unstaged_deleted { println!(" deleted: {}", f); }
|
||||
println!();
|
||||
}
|
||||
}
|
||||
|
||||
if !untracked.is_empty() {
|
||||
println!("Untracked files:");
|
||||
for f in &untracked { println!(" {}", f); }
|
||||
println!();
|
||||
}
|
||||
|
||||
if !anything && untracked.is_empty() {
|
||||
println!("nothing to commit, working tree clean");
|
||||
}
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Pretty-print any stored object.
|
||||
fn cmd_cat_file(hash: String, type_only: bool) -> Result<(), String> {
|
||||
let repo = Repo::find()?;
|
||||
let obj = repo.store.read(&hash)?;
|
||||
|
||||
if type_only {
|
||||
println!("{}", obj.kind);
|
||||
return Ok(());
|
||||
}
|
||||
|
||||
match &obj.data {
|
||||
ObjData::Blob(bytes) => match std::str::from_utf8(bytes) {
|
||||
Ok(s) => print!("{}", s),
|
||||
Err(_) => println!("<binary blob: {} bytes>", bytes.len()),
|
||||
},
|
||||
|
||||
ObjData::Tree(entries) => {
|
||||
for e in entries {
|
||||
let kind = if e.mode == "040000" { "tree" } else { "blob" };
|
||||
println!("{} {} {}\t{}", e.mode, kind, hex::encode(&e.hash), e.name);
|
||||
}
|
||||
}
|
||||
|
||||
ObjData::Commit { tree, parents, author, committer, message } => {
|
||||
println!("tree {}", tree);
|
||||
for p in parents { println!("parent {}", p); }
|
||||
println!("author {}", author);
|
||||
println!("committer {}", committer);
|
||||
println!();
|
||||
println!("{}", message);
|
||||
}
|
||||
}
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Hash a file as a blob. With -w, also write it to the object store.
|
||||
fn cmd_hash_object(file: String, write: bool) -> Result<(), String> {
|
||||
let content = std::fs::read(&file).map_err(|e| e.to_string())?;
|
||||
let blob = Object::blob(content);
|
||||
let hash = blob.hash_hex();
|
||||
|
||||
if write {
|
||||
let repo = Repo::find()?;
|
||||
repo.store.write(&blob)?;
|
||||
println!("{}", hash);
|
||||
} else {
|
||||
// Dry run: just print, nothing stored
|
||||
println!("{}", hash);
|
||||
}
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
// ── Internal helpers ───────────────────────────────────────────────────────
|
||||
|
||||
/// Recursively walk the working directory and collect files not in the index.
|
||||
fn collect_untracked(
|
||||
base: &Path,
|
||||
dir: &Path,
|
||||
index: &std::collections::HashMap<String, String>,
|
||||
out: &mut Vec<String>,
|
||||
) -> Result<(), String> {
|
||||
for entry in std::fs::read_dir(dir).map_err(|e| e.to_string())? {
|
||||
let entry = entry.map_err(|e| e.to_string())?;
|
||||
let path = entry.path();
|
||||
let name = entry.file_name().to_string_lossy().to_string();
|
||||
|
||||
// Skip the .version directory itself
|
||||
if name == ".version" || name.starts_with('.') {
|
||||
continue;
|
||||
}
|
||||
|
||||
if path.is_dir() {
|
||||
collect_untracked(base, &path, index, out)?;
|
||||
} else {
|
||||
let rel = path
|
||||
.strip_prefix(base)
|
||||
.unwrap_or(&path)
|
||||
.to_string_lossy()
|
||||
.to_string();
|
||||
if !index.contains_key(&rel) {
|
||||
out.push(rel);
|
||||
}
|
||||
}
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
197
src/objects.rs
Normal file
197
src/objects.rs
Normal file
|
|
@ -0,0 +1,197 @@
|
|||
/// Git-compatible object model.
|
||||
///
|
||||
/// Every object is stored as: "<type> <size>\0<content>"
|
||||
/// That header + content is SHA-1 hashed → the object's identity.
|
||||
/// The bytes are then zlib-compressed and written to disk.
|
||||
///
|
||||
/// Three object types:
|
||||
/// blob – raw file content
|
||||
/// tree – sorted list of (mode, name, 20-byte-hash) entries
|
||||
/// commit – metadata pointing to a tree + optional parent commit(s)
|
||||
use sha1::{Digest, Sha1};
|
||||
use std::fmt;
|
||||
|
||||
// ── Types ──────────────────────────────────────────────────────────────────
|
||||
|
||||
#[derive(Debug, Clone, PartialEq)]
|
||||
pub enum ObjKind {
|
||||
Blob,
|
||||
Tree,
|
||||
Commit,
|
||||
}
|
||||
|
||||
impl fmt::Display for ObjKind {
|
||||
fn fmt(&self, f: &mut fmt::Formatter) -> fmt::Result {
|
||||
match self {
|
||||
ObjKind::Blob => write!(f, "blob"),
|
||||
ObjKind::Tree => write!(f, "tree"),
|
||||
ObjKind::Commit => write!(f, "commit"),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// One entry inside a tree object.
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct TreeEntry {
|
||||
pub mode: String, // "100644" file | "040000" dir
|
||||
pub name: String,
|
||||
pub hash: [u8; 20], // raw 20-byte SHA-1
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone)]
|
||||
pub enum ObjData {
|
||||
Blob(Vec<u8>),
|
||||
Tree(Vec<TreeEntry>),
|
||||
Commit {
|
||||
tree: String, // SHA-1 hex of the root tree
|
||||
parents: Vec<String>, // SHA-1 hex of parent commit(s)
|
||||
author: String,
|
||||
committer: String,
|
||||
message: String,
|
||||
},
|
||||
}
|
||||
|
||||
pub struct Object {
|
||||
pub kind: ObjKind,
|
||||
pub data: ObjData,
|
||||
}
|
||||
|
||||
// ── Constructors ───────────────────────────────────────────────────────────
|
||||
|
||||
impl Object {
|
||||
pub fn blob(content: Vec<u8>) -> Self {
|
||||
Object { kind: ObjKind::Blob, data: ObjData::Blob(content) }
|
||||
}
|
||||
|
||||
pub fn tree(entries: Vec<TreeEntry>) -> Self {
|
||||
Object { kind: ObjKind::Tree, data: ObjData::Tree(entries) }
|
||||
}
|
||||
|
||||
pub fn commit(
|
||||
tree: String, parents: Vec<String>,
|
||||
author: String, committer: String, message: String,
|
||||
) -> Self {
|
||||
Object {
|
||||
kind: ObjKind::Commit,
|
||||
data: ObjData::Commit { tree, parents, author, committer, message },
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// ── Serialization ──────────────────────────────────────────────────────────
|
||||
|
||||
impl Object {
|
||||
/// Full wire bytes: "<kind> <len>\0<body>"
|
||||
pub fn serialize(&self) -> Vec<u8> {
|
||||
let body = self.body_bytes();
|
||||
let mut out = format!("{} {}\0", self.kind, body.len()).into_bytes();
|
||||
out.extend(body);
|
||||
out
|
||||
}
|
||||
|
||||
fn body_bytes(&self) -> Vec<u8> {
|
||||
match &self.data {
|
||||
ObjData::Blob(b) => b.clone(),
|
||||
|
||||
ObjData::Tree(entries) => {
|
||||
let mut out = Vec::new();
|
||||
let mut sorted = entries.clone();
|
||||
// Git sorts tree entries lexicographically by name
|
||||
sorted.sort_by(|a, b| a.name.cmp(&b.name));
|
||||
for e in sorted {
|
||||
out.extend(format!("{} {}\0", e.mode, e.name).as_bytes());
|
||||
out.extend(&e.hash); // raw 20 bytes, not hex
|
||||
}
|
||||
out
|
||||
}
|
||||
|
||||
ObjData::Commit { tree, parents, author, committer, message } => {
|
||||
let mut s = format!("tree {}\n", tree);
|
||||
for p in parents { s.push_str(&format!("parent {}\n", p)); }
|
||||
s.push_str(&format!("author {}\n", author));
|
||||
s.push_str(&format!("committer {}\n", committer));
|
||||
s.push('\n');
|
||||
s.push_str(message);
|
||||
s.into_bytes()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// SHA-1 of the full wire bytes, as a 40-char hex string.
|
||||
pub fn hash_hex(&self) -> String {
|
||||
let mut h = Sha1::new();
|
||||
h.update(self.serialize());
|
||||
hex::encode(h.finalize())
|
||||
}
|
||||
}
|
||||
|
||||
// ── Deserialization ────────────────────────────────────────────────────────
|
||||
|
||||
impl Object {
|
||||
pub fn parse(raw: &[u8]) -> Result<Self, String> {
|
||||
let nul = raw.iter().position(|&b| b == 0).ok_or("object missing null byte")?;
|
||||
let header = std::str::from_utf8(&raw[..nul]).map_err(|e| e.to_string())?;
|
||||
let (type_str, _size) = header.split_once(' ').ok_or("malformed object header")?;
|
||||
let body = &raw[nul + 1..];
|
||||
|
||||
match type_str {
|
||||
"blob" => Ok(Object::blob(body.to_vec())),
|
||||
"tree" => Ok(Object::tree(parse_tree(body)?)),
|
||||
"commit" => {
|
||||
let data = parse_commit(body)?;
|
||||
Ok(Object { kind: ObjKind::Commit, data })
|
||||
}
|
||||
t => Err(format!("unknown object type: {}", t)),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn parse_tree(data: &[u8]) -> Result<Vec<TreeEntry>, String> {
|
||||
let mut entries = Vec::new();
|
||||
let mut i = 0;
|
||||
while i < data.len() {
|
||||
// "<mode> <name>\0<20-bytes>"
|
||||
let sp = data[i..].iter().position(|&b| b == b' ')
|
||||
.ok_or("tree entry: missing space")?;
|
||||
let mode = std::str::from_utf8(&data[i..i + sp])
|
||||
.map_err(|e| e.to_string())?.to_string();
|
||||
i += sp + 1;
|
||||
|
||||
let nul = data[i..].iter().position(|&b| b == 0)
|
||||
.ok_or("tree entry: missing null")?;
|
||||
let name = std::str::from_utf8(&data[i..i + nul])
|
||||
.map_err(|e| e.to_string())?.to_string();
|
||||
i += nul + 1;
|
||||
|
||||
if i + 20 > data.len() { return Err("tree entry: truncated hash".into()); }
|
||||
let mut hash = [0u8; 20];
|
||||
hash.copy_from_slice(&data[i..i + 20]);
|
||||
i += 20;
|
||||
|
||||
entries.push(TreeEntry { mode, name, hash });
|
||||
}
|
||||
Ok(entries)
|
||||
}
|
||||
|
||||
fn parse_commit(data: &[u8]) -> Result<ObjData, String> {
|
||||
let text = std::str::from_utf8(data).map_err(|e| e.to_string())?;
|
||||
let mut tree = String::new();
|
||||
let mut parents = Vec::new();
|
||||
let mut author = String::new();
|
||||
let mut committer = String::new();
|
||||
let mut msg_lines: Vec<&str> = Vec::new();
|
||||
let mut in_msg = false;
|
||||
|
||||
for line in text.lines() {
|
||||
if in_msg {
|
||||
msg_lines.push(line);
|
||||
} else if line.is_empty() {
|
||||
in_msg = true;
|
||||
} else if let Some(v) = line.strip_prefix("tree ") { tree = v.to_string(); }
|
||||
else if let Some(v) = line.strip_prefix("parent ") { parents.push(v.to_string()); }
|
||||
else if let Some(v) = line.strip_prefix("author ") { author = v.to_string(); }
|
||||
else if let Some(v) = line.strip_prefix("committer ") { committer = v.to_string(); }
|
||||
}
|
||||
|
||||
Ok(ObjData::Commit { tree, parents, author, committer, message: msg_lines.join("\n") })
|
||||
}
|
||||
211
src/repo.rs
Normal file
211
src/repo.rs
Normal file
|
|
@ -0,0 +1,211 @@
|
|||
/// High-level repository operations: init, index (staging area), refs, tree building.
|
||||
use std::collections::{BTreeSet, HashMap};
|
||||
use std::fs;
|
||||
use std::path::{Path, PathBuf};
|
||||
|
||||
use crate::objects::{ObjData, Object, TreeEntry};
|
||||
use crate::store::Store;
|
||||
|
||||
// ── Repository ─────────────────────────────────────────────────────────────
|
||||
|
||||
pub struct Repo {
|
||||
pub root: PathBuf, // working-directory root (where .version/ lives)
|
||||
pub dot: PathBuf, // .version/
|
||||
pub store: Store,
|
||||
}
|
||||
|
||||
impl Repo {
|
||||
// ── Open / Init ────────────────────────────────────────────────────────
|
||||
|
||||
/// Walk up from cwd until we find a .version/ directory.
|
||||
pub fn find() -> Result<Self, String> {
|
||||
let mut dir = std::env::current_dir().map_err(|e| e.to_string())?;
|
||||
loop {
|
||||
let dot = dir.join(".version");
|
||||
if dot.is_dir() {
|
||||
return Ok(Repo { store: Store::new(dot.clone()), root: dir, dot });
|
||||
}
|
||||
if !dir.pop() {
|
||||
return Err(
|
||||
"not a vcs repository (no .version directory found — run `vcs init`)".into(),
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Create a new repository in `path`.
|
||||
pub fn init(path: &Path) -> Result<Self, String> {
|
||||
let dot = path.join(".version");
|
||||
fs::create_dir_all(dot.join("objects")).map_err(|e| e.to_string())?;
|
||||
fs::create_dir_all(dot.join("refs/heads")).map_err(|e| e.to_string())?;
|
||||
// HEAD starts on branch "main" (symbolic ref)
|
||||
fs::write(dot.join("HEAD"), "ref: refs/heads/main\n").map_err(|e| e.to_string())?;
|
||||
// Empty index
|
||||
fs::write(dot.join("index"), "").map_err(|e| e.to_string())?;
|
||||
|
||||
println!("Initialized empty repository in {}", dot.display());
|
||||
Ok(Repo { store: Store::new(dot.clone()), root: path.to_path_buf(), dot })
|
||||
}
|
||||
|
||||
// ── Index (staging area) ───────────────────────────────────────────────
|
||||
//
|
||||
// Simple text format, one entry per line:
|
||||
// <sha1-hex> <repo-relative-path>
|
||||
//
|
||||
// (Real Git uses a binary format with stat info for fast dirty checking.)
|
||||
|
||||
pub fn read_index(&self) -> Result<HashMap<String, String>, String> {
|
||||
let text = fs::read_to_string(self.dot.join("index")).map_err(|e| e.to_string())?;
|
||||
let mut map = HashMap::new();
|
||||
for line in text.lines() {
|
||||
if line.is_empty() { continue; }
|
||||
let (hash, path) = line.split_once(' ').ok_or("index: malformed line")?;
|
||||
map.insert(path.to_string(), hash.to_string());
|
||||
}
|
||||
Ok(map)
|
||||
}
|
||||
|
||||
pub fn write_index(&self, index: &HashMap<String, String>) -> Result<(), String> {
|
||||
let mut lines: Vec<String> =
|
||||
index.iter().map(|(p, h)| format!("{} {}", h, p)).collect();
|
||||
lines.sort();
|
||||
let content = lines.join("\n") + if lines.is_empty() { "" } else { "\n" };
|
||||
fs::write(self.dot.join("index"), content).map_err(|e| e.to_string())
|
||||
}
|
||||
|
||||
// ── Refs ───────────────────────────────────────────────────────────────
|
||||
//
|
||||
// HEAD is either:
|
||||
// "ref: refs/heads/main" ← symbolic ref (normal case)
|
||||
// "<sha1>" ← detached HEAD
|
||||
//
|
||||
// Branch files (refs/heads/main) contain a single SHA-1 line.
|
||||
|
||||
pub fn read_head(&self) -> Result<String, String> {
|
||||
fs::read_to_string(self.dot.join("HEAD"))
|
||||
.map(|s| s.trim().to_string())
|
||||
.map_err(|e| e.to_string())
|
||||
}
|
||||
|
||||
/// Resolve HEAD to a commit hash, or None if no commits yet.
|
||||
pub fn head_commit(&self) -> Result<Option<String>, String> {
|
||||
let head = self.read_head()?;
|
||||
if let Some(refname) = head.strip_prefix("ref: ") {
|
||||
let ref_path = self.dot.join(refname);
|
||||
if !ref_path.exists() { return Ok(None); }
|
||||
let hash = fs::read_to_string(ref_path).map_err(|e| e.to_string())?;
|
||||
Ok(Some(hash.trim().to_string()))
|
||||
} else {
|
||||
Ok(Some(head))
|
||||
}
|
||||
}
|
||||
|
||||
/// Update the branch that HEAD points to (or HEAD itself if detached).
|
||||
pub fn update_head(&self, hash: &str) -> Result<(), String> {
|
||||
let head = self.read_head()?;
|
||||
if let Some(refname) = head.strip_prefix("ref: ") {
|
||||
let ref_path = self.dot.join(refname);
|
||||
fs::create_dir_all(ref_path.parent().unwrap()).map_err(|e| e.to_string())?;
|
||||
fs::write(ref_path, format!("{}\n", hash)).map_err(|e| e.to_string())
|
||||
} else {
|
||||
fs::write(self.dot.join("HEAD"), format!("{}\n", hash)).map_err(|e| e.to_string())
|
||||
}
|
||||
}
|
||||
|
||||
// ── Tree building ──────────────────────────────────────────────────────
|
||||
|
||||
/// Build a tree object hierarchy from the flat index map and return the
|
||||
/// root tree hash. Handles subdirectories by recursing.
|
||||
pub fn build_tree(&self, index: &HashMap<String, String>) -> Result<String, String> {
|
||||
build_tree_recursive(&self.store, index, "")
|
||||
}
|
||||
|
||||
/// Read the tree for the current HEAD commit (path → hash).
|
||||
pub fn head_tree(&self) -> Result<HashMap<String, String>, String> {
|
||||
let Some(commit_hash) = self.head_commit()? else {
|
||||
return Ok(HashMap::new());
|
||||
};
|
||||
let commit = self.store.read(&commit_hash)?;
|
||||
let ObjData::Commit { tree, .. } = &commit.data else {
|
||||
return Err("HEAD is not a commit object".into());
|
||||
};
|
||||
flat_tree(&self.store, tree, "")
|
||||
}
|
||||
}
|
||||
|
||||
// ── Free helpers ───────────────────────────────────────────────────────────
|
||||
|
||||
/// Recursively create tree objects for `prefix` directory and below.
|
||||
/// `index` is the flat map { "src/main.rs" → sha1-hex, ... }.
|
||||
fn build_tree_recursive(
|
||||
store: &Store,
|
||||
index: &HashMap<String, String>,
|
||||
prefix: &str,
|
||||
) -> Result<String, String> {
|
||||
let mut file_entries: Vec<TreeEntry> = Vec::new();
|
||||
let mut subdirs: BTreeSet<String> = BTreeSet::new();
|
||||
|
||||
for (path, hash_hex) in index {
|
||||
// Compute the path relative to current prefix
|
||||
let rel = if prefix.is_empty() {
|
||||
path.as_str()
|
||||
} else {
|
||||
match path.strip_prefix(&format!("{}/", prefix)) {
|
||||
Some(r) => r,
|
||||
None => continue, // not under this prefix
|
||||
}
|
||||
};
|
||||
|
||||
if let Some(slash) = rel.find('/') {
|
||||
// There is a subdirectory component
|
||||
subdirs.insert(rel[..slash].to_string());
|
||||
} else {
|
||||
// Direct file entry
|
||||
let mut hash = [0u8; 20];
|
||||
hex::decode_to_slice(hash_hex, &mut hash)
|
||||
.map_err(|e| format!("index has bad hash '{}': {}", hash_hex, e))?;
|
||||
file_entries.push(TreeEntry { mode: "100644".into(), name: rel.to_string(), hash });
|
||||
}
|
||||
}
|
||||
|
||||
// Recurse into each subdirectory and collect its tree hash
|
||||
let mut entries = file_entries;
|
||||
for subdir in subdirs {
|
||||
let child_prefix = if prefix.is_empty() {
|
||||
subdir.clone()
|
||||
} else {
|
||||
format!("{}/{}", prefix, subdir)
|
||||
};
|
||||
let child_hash = build_tree_recursive(store, index, &child_prefix)?;
|
||||
let mut hash = [0u8; 20];
|
||||
hex::decode_to_slice(&child_hash, &mut hash)
|
||||
.map_err(|e| format!("bad child tree hash: {}", e))?;
|
||||
entries.push(TreeEntry { mode: "040000".into(), name: subdir, hash });
|
||||
}
|
||||
|
||||
store.write(&Object::tree(entries))
|
||||
}
|
||||
|
||||
/// Flatten a tree object into a { path → sha1-hex } map (recurses into subtrees).
|
||||
fn flat_tree(store: &Store, tree_hash: &str, prefix: &str) -> Result<HashMap<String, String>, String> {
|
||||
let obj = store.read(tree_hash)?;
|
||||
let ObjData::Tree(entries) = &obj.data else {
|
||||
return Err(format!("{} is not a tree", &tree_hash[..7]));
|
||||
};
|
||||
let mut map = HashMap::new();
|
||||
for e in entries {
|
||||
let full_name = if prefix.is_empty() {
|
||||
e.name.clone()
|
||||
} else {
|
||||
format!("{}/{}", prefix, e.name)
|
||||
};
|
||||
if e.mode == "040000" {
|
||||
// subtree — recurse
|
||||
let sub = flat_tree(store, &hex::encode(&e.hash), &full_name)?;
|
||||
map.extend(sub);
|
||||
} else {
|
||||
map.insert(full_name, hex::encode(&e.hash));
|
||||
}
|
||||
}
|
||||
Ok(map)
|
||||
}
|
||||
100
src/store.rs
Normal file
100
src/store.rs
Normal file
|
|
@ -0,0 +1,100 @@
|
|||
/// Low-level object store: read/write zlib-compressed object files.
|
||||
///
|
||||
/// Layout inside .version/objects/:
|
||||
/// ab/cdef1234... (first 2 hex chars = directory, rest = filename)
|
||||
///
|
||||
/// This mirrors Git's "loose object" storage exactly.
|
||||
use std::fs;
|
||||
use std::io::{Read, Write};
|
||||
use std::path::PathBuf;
|
||||
|
||||
use flate2::{read::ZlibDecoder, write::ZlibEncoder, Compression};
|
||||
|
||||
use crate::objects::Object;
|
||||
|
||||
pub struct Store {
|
||||
pub root: PathBuf, // path to .version/
|
||||
}
|
||||
|
||||
impl Store {
|
||||
pub fn new(root: PathBuf) -> Self {
|
||||
Store { root }
|
||||
}
|
||||
|
||||
// ── Write ──────────────────────────────────────────────────────────────
|
||||
|
||||
/// Hash the object, compress it, and write it under objects/<2>/<38>.
|
||||
/// Returns the 40-char hex hash.
|
||||
pub fn write(&self, obj: &Object) -> Result<String, String> {
|
||||
let hash = obj.hash_hex();
|
||||
let path = self.object_path(&hash);
|
||||
|
||||
// Idempotent: skip if already stored.
|
||||
if path.exists() {
|
||||
return Ok(hash);
|
||||
}
|
||||
|
||||
fs::create_dir_all(path.parent().unwrap()).map_err(|e| e.to_string())?;
|
||||
|
||||
let raw = obj.serialize();
|
||||
let mut enc = ZlibEncoder::new(Vec::new(), Compression::default());
|
||||
enc.write_all(&raw).map_err(|e| e.to_string())?;
|
||||
let compressed = enc.finish().map_err(|e| e.to_string())?;
|
||||
|
||||
fs::write(&path, compressed).map_err(|e| e.to_string())?;
|
||||
Ok(hash)
|
||||
}
|
||||
|
||||
// ── Read ───────────────────────────────────────────────────────────────
|
||||
|
||||
/// Decompress and parse an object by its hash (full or abbreviated).
|
||||
pub fn read(&self, hash: &str) -> Result<Object, String> {
|
||||
let full = self.resolve(hash)?;
|
||||
let path = self.object_path(&full);
|
||||
let compressed = fs::read(&path)
|
||||
.map_err(|_| format!("object not found: {}", &full[..7]))?;
|
||||
|
||||
let mut dec = ZlibDecoder::new(&compressed[..]);
|
||||
let mut raw = Vec::new();
|
||||
dec.read_to_end(&mut raw).map_err(|e| e.to_string())?;
|
||||
|
||||
Object::parse(&raw)
|
||||
}
|
||||
|
||||
// ── Helpers ────────────────────────────────────────────────────────────
|
||||
|
||||
fn object_path(&self, hash: &str) -> PathBuf {
|
||||
let (dir, file) = hash.split_at(2);
|
||||
self.root.join("objects").join(dir).join(file)
|
||||
}
|
||||
|
||||
/// Resolve an abbreviated hash (minimum 4 chars) to the full 40-char form.
|
||||
fn resolve(&self, prefix: &str) -> Result<String, String> {
|
||||
if prefix.len() == 40 {
|
||||
return Ok(prefix.to_string());
|
||||
}
|
||||
if prefix.len() < 4 {
|
||||
return Err("hash prefix too short (need at least 4 chars)".into());
|
||||
}
|
||||
|
||||
let (dir, rest) = prefix.split_at(2);
|
||||
let dir_path = self.root.join("objects").join(dir);
|
||||
if !dir_path.exists() {
|
||||
return Err(format!("no object starting with {}", prefix));
|
||||
}
|
||||
|
||||
let mut matches: Vec<String> = fs::read_dir(&dir_path)
|
||||
.map_err(|e| e.to_string())?
|
||||
.filter_map(|e| e.ok())
|
||||
.map(|e| e.file_name().to_string_lossy().to_string())
|
||||
.filter(|name| name.starts_with(rest))
|
||||
.map(|name| format!("{}{}", dir, name))
|
||||
.collect();
|
||||
|
||||
match matches.len() {
|
||||
0 => Err(format!("no object starting with {}", prefix)),
|
||||
1 => Ok(matches.remove(0)),
|
||||
_ => Err(format!("ambiguous prefix '{}' ({} matches)", prefix, matches.len())),
|
||||
}
|
||||
}
|
||||
}
|
||||
Loading…
Add table
Reference in a new issue