Преглед изворни кода

Port remaining dulwich.cli commands to argparse

Jelmer Vernooij пре 2 месеци
родитељ
комит
b82cbd2e76
5 измењених фајлова са 939 додато и 244 уклоњено
  1. 3 0
      NEWS
  2. 259 240
      dulwich/cli.py
  3. 5 4
      dulwich/porcelain.py
  4. 1 0
      tests/__init__.py
  5. 671 0
      tests/test_cli.py

+ 3 - 0
NEWS

@@ -52,6 +52,9 @@
  * Add basic support for reading git commit graphs.
    (Jelmer Vernooij, #1191)
 
+ * Port remaining ``dulwich.cli`` commands from getopt to argparse.
+   (Jelmer Vernooij)
+
 0.22.8	2025-03-02
 
  * Allow passing in plain strings to ``dulwich.porcelain.tag_create``

+ 259 - 240
dulwich/cli.py

@@ -29,11 +29,9 @@ a way to test Dulwich.
 """
 
 import argparse
-import optparse
 import os
 import signal
 import sys
-from getopt import getopt
 from pathlib import Path
 from typing import TYPE_CHECKING, ClassVar, Optional
 
@@ -47,8 +45,7 @@ from .pack import Pack, sha_to_hex
 from .repo import Repo
 
 if TYPE_CHECKING:
-    from .objects import ObjectID
-    from .refs import Ref
+    pass
 
 
 def signal_int(signal, frame) -> None:
@@ -88,27 +85,37 @@ class cmd_archive(Command):
                 write_error=sys.stderr.write,
             )
         else:
+            # Use buffer if available (for binary output), otherwise use stdout
+            outstream = getattr(sys.stdout, "buffer", sys.stdout)
             porcelain.archive(
-                ".", args.committish, outstream=sys.stdout.buffer, errstream=sys.stderr
+                ".", args.committish, outstream=outstream, errstream=sys.stderr
             )
 
 
 class cmd_add(Command):
     def run(self, argv) -> None:
         parser = argparse.ArgumentParser()
-        parser.add_argument("path", type=Path, nargs="+")
+        parser.add_argument("path", nargs="+")
         args = parser.parse_args(argv)
 
-        porcelain.add(".", paths=args.path)
+        # Convert '.' to None to add all files
+        paths = args.path
+        if len(paths) == 1 and paths[0] == ".":
+            paths = None
+
+        porcelain.add(".", paths=paths)
 
 
 class cmd_rm(Command):
     def run(self, argv) -> None:
         parser = argparse.ArgumentParser()
+        parser.add_argument(
+            "--cached", action="store_true", help="Remove from index only"
+        )
         parser.add_argument("path", type=Path, nargs="+")
         args = parser.parse_args(argv)
 
-        porcelain.rm(".", paths=args.path)
+        porcelain.remove(".", paths=args.path, cached=args.cached)
 
 
 class cmd_fetch_pack(Command):
@@ -124,9 +131,7 @@ class cmd_fetch_pack(Command):
             determine_wants = r.object_store.determine_wants_all
         else:
 
-            def determine_wants(
-                refs: dict[Ref, ObjectID], depth: Optional[int] = None
-            ) -> list[ObjectID]:
+            def determine_wants(refs, depth: Optional[int] = None):
                 return [y.encode("utf-8") for y in args.refs if y not in r.object_store]
 
         client.fetch(path, r, determine_wants)
@@ -134,9 +139,10 @@ class cmd_fetch_pack(Command):
 
 class cmd_fetch(Command):
     def run(self, args) -> None:
-        opts, args = getopt(args, "", [])
-        dict(opts)
-        client, path = get_transport_and_path(args.pop(0))
+        parser = argparse.ArgumentParser()
+        parser.add_argument("location", help="Remote location to fetch from")
+        args = parser.parse_args(args)
+        client, path = get_transport_and_path(args.location)
         r = Repo(".")
 
         def progress(msg: bytes) -> None:
@@ -159,47 +165,49 @@ class cmd_for_each_ref(Command):
 
 class cmd_fsck(Command):
     def run(self, args) -> None:
-        opts, args = getopt(args, "", [])
-        dict(opts)
+        parser = argparse.ArgumentParser()
+        parser.parse_args(args)
         for obj, msg in porcelain.fsck("."):
             print(f"{obj}: {msg}")
 
 
 class cmd_log(Command):
     def run(self, args) -> None:
-        parser = optparse.OptionParser()
-        parser.add_option(
+        parser = argparse.ArgumentParser()
+        parser.add_argument(
             "--reverse",
-            dest="reverse",
             action="store_true",
             help="Reverse order in which entries are printed",
         )
-        parser.add_option(
+        parser.add_argument(
             "--name-status",
-            dest="name_status",
             action="store_true",
             help="Print name/status for each changed file",
         )
-        options, args = parser.parse_args(args)
+        parser.add_argument("paths", nargs="*", help="Paths to show log for")
+        args = parser.parse_args(args)
 
         porcelain.log(
             ".",
-            paths=args,
-            reverse=options.reverse,
-            name_status=options.name_status,
+            paths=args.paths,
+            reverse=args.reverse,
+            name_status=args.name_status,
             outstream=sys.stdout,
         )
 
 
 class cmd_diff(Command):
     def run(self, args) -> None:
-        opts, args = getopt(args, "", [])
+        parser = argparse.ArgumentParser()
+        parser.add_argument(
+            "commit", nargs="?", default="HEAD", help="Commit to show diff for"
+        )
+        args = parser.parse_args(args)
 
         r = Repo(".")
-        if args == []:
-            commit_id = b"HEAD"
-        else:
-            commit_id = args[0]
+        commit_id = (
+            args.commit.encode() if isinstance(args.commit, str) else args.commit
+        )
         commit = parse_commit(r, commit_id)
         parent_commit = r[commit.parents[0]]
         porcelain.diff_tree(
@@ -209,13 +217,11 @@ class cmd_diff(Command):
 
 class cmd_dump_pack(Command):
     def run(self, args) -> None:
-        opts, args = getopt(args, "", [])
-
-        if args == []:
-            print("Usage: dulwich dump-pack FILENAME")
-            sys.exit(1)
+        parser = argparse.ArgumentParser()
+        parser.add_argument("filename", help="Pack file to dump")
+        args = parser.parse_args(args)
 
-        basename, _ = os.path.splitext(args[0])
+        basename, _ = os.path.splitext(args.filename)
         x = Pack(basename)
         print(f"Object names checksum: {x.name()}")
         print(f"Checksum: {sha_to_hex(x.get_stored_checksum())}")
@@ -232,14 +238,11 @@ class cmd_dump_pack(Command):
 
 class cmd_dump_index(Command):
     def run(self, args) -> None:
-        opts, args = getopt(args, "", [])
-
-        if args == []:
-            print("Usage: dulwich dump-index FILENAME")
-            sys.exit(1)
+        parser = argparse.ArgumentParser()
+        parser.add_argument("filename", help="Index file to dump")
+        args = parser.parse_args(args)
 
-        filename = args[0]
-        idx = Index(filename)
+        idx = Index(args.filename)
 
         for o in idx:
             print(o, idx[o])
@@ -247,76 +250,64 @@ class cmd_dump_index(Command):
 
 class cmd_init(Command):
     def run(self, args) -> None:
-        opts, args = getopt(args, "", ["bare"])
-        kwopts = dict(opts)
-
-        if args == []:
-            path = os.getcwd()
-        else:
-            path = args[0]
+        parser = argparse.ArgumentParser()
+        parser.add_argument(
+            "--bare", action="store_true", help="Create a bare repository"
+        )
+        parser.add_argument(
+            "path", nargs="?", default=os.getcwd(), help="Repository path"
+        )
+        args = parser.parse_args(args)
 
-        porcelain.init(path, bare=("--bare" in kwopts))
+        porcelain.init(args.path, bare=args.bare)
 
 
 class cmd_clone(Command):
     def run(self, args) -> None:
-        parser = optparse.OptionParser()
-        parser.add_option(
+        parser = argparse.ArgumentParser()
+        parser.add_argument(
             "--bare",
-            dest="bare",
             help="Whether to create a bare repository.",
             action="store_true",
         )
-        parser.add_option(
-            "--depth", dest="depth", type=int, help="Depth at which to fetch"
-        )
-        parser.add_option(
+        parser.add_argument("--depth", type=int, help="Depth at which to fetch")
+        parser.add_argument(
             "-b",
             "--branch",
-            dest="branch",
             type=str,
-            help=("Check out branch instead of branch pointed to by remote HEAD"),
+            help="Check out branch instead of branch pointed to by remote HEAD",
         )
-        parser.add_option(
+        parser.add_argument(
             "--refspec",
-            dest="refspec",
             type=str,
             help="References to fetch",
             action="append",
         )
-        parser.add_option(
+        parser.add_argument(
             "--filter",
             dest="filter_spec",
             type=str,
             help="git-rev-list-style object filter",
         )
-        parser.add_option(
+        parser.add_argument(
             "--protocol",
             type=int,
             help="Git protocol version to use",
         )
-        options, args = parser.parse_args(args)
-
-        if args == []:
-            print("usage: dulwich clone host:path [PATH]")
-            sys.exit(1)
-
-        source = args.pop(0)
-        if len(args) > 0:
-            target = args.pop(0)
-        else:
-            target = None
+        parser.add_argument("source", help="Repository to clone from")
+        parser.add_argument("target", nargs="?", help="Directory to clone into")
+        args = parser.parse_args(args)
 
         try:
             porcelain.clone(
-                source,
-                target,
-                bare=options.bare,
-                depth=options.depth,
-                branch=options.branch,
-                refspec=options.refspec,
-                filter_spec=options.filter_spec,
-                protocol_version=options.protocol,
+                args.source,
+                args.target,
+                bare=args.bare,
+                depth=args.depth,
+                branch=args.branch,
+                refspec=args.refspec,
+                filter_spec=args.filter_spec,
+                protocol_version=args.protocol,
             )
         except GitProtocolError as e:
             print(f"{e}")
@@ -324,19 +315,19 @@ class cmd_clone(Command):
 
 class cmd_commit(Command):
     def run(self, args) -> None:
-        opts, args = getopt(args, "", ["message="])
-        kwopts = dict(opts)
-        porcelain.commit(".", message=kwopts["--message"])
+        parser = argparse.ArgumentParser()
+        parser.add_argument("--message", "-m", required=True, help="Commit message")
+        args = parser.parse_args(args)
+        porcelain.commit(".", message=args.message)
 
 
 class cmd_commit_tree(Command):
     def run(self, args) -> None:
-        opts, args = getopt(args, "", ["message="])
-        if args == []:
-            print("usage: dulwich commit-tree tree")
-            sys.exit(1)
-        kwopts = dict(opts)
-        porcelain.commit_tree(".", tree=args[0], message=kwopts["--message"])
+        parser = argparse.ArgumentParser()
+        parser.add_argument("--message", "-m", required=True, help="Commit message")
+        parser.add_argument("tree", help="Tree SHA to commit")
+        args = parser.parse_args(args)
+        porcelain.commit_tree(".", tree=args.tree, message=args.message)
 
 
 class cmd_update_server_info(Command):
@@ -346,13 +337,32 @@ class cmd_update_server_info(Command):
 
 class cmd_symbolic_ref(Command):
     def run(self, args) -> None:
-        opts, args = getopt(args, "", ["ref-name", "force"])
-        if not args:
-            print("Usage: dulwich symbolic-ref REF_NAME [--force]")
-            sys.exit(1)
+        parser = argparse.ArgumentParser()
+        parser.add_argument("name", help="Symbolic reference name")
+        parser.add_argument("ref", nargs="?", help="Target reference")
+        parser.add_argument("--force", action="store_true", help="Force update")
+        args = parser.parse_args(args)
 
-        ref_name = args.pop(0)
-        porcelain.symbolic_ref(".", ref_name=ref_name, force="--force" in args)
+        # If ref is provided, we're setting; otherwise we're reading
+        if args.ref:
+            # Set symbolic reference
+            from .repo import Repo
+
+            with Repo(".") as repo:
+                repo.refs.set_symbolic_ref(args.name.encode(), args.ref.encode())
+        else:
+            # Read symbolic reference
+            from .repo import Repo
+
+            with Repo(".") as repo:
+                try:
+                    target = repo.refs.read_ref(args.name.encode())
+                    if target.startswith(b"ref: "):
+                        print(target[5:].decode())
+                    else:
+                        print(target.decode())
+                except KeyError:
+                    print(f"fatal: ref '{args.name}' is not a symbolic ref")
 
 
 class cmd_pack_refs(Command):
@@ -372,68 +382,81 @@ class cmd_show(Command):
         parser = argparse.ArgumentParser()
         parser.add_argument("objectish", type=str, nargs="*")
         args = parser.parse_args(argv)
-        porcelain.show(".", args.objectish or None)
+        porcelain.show(".", args.objectish or None, outstream=sys.stdout)
 
 
 class cmd_diff_tree(Command):
     def run(self, args) -> None:
-        opts, args = getopt(args, "", [])
-        if len(args) < 2:
-            print("Usage: dulwich diff-tree OLD-TREE NEW-TREE")
-            sys.exit(1)
-        porcelain.diff_tree(".", args[0], args[1])
+        parser = argparse.ArgumentParser()
+        parser.add_argument("old_tree", help="Old tree SHA")
+        parser.add_argument("new_tree", help="New tree SHA")
+        args = parser.parse_args(args)
+        porcelain.diff_tree(".", args.old_tree, args.new_tree)
 
 
 class cmd_rev_list(Command):
     def run(self, args) -> None:
-        opts, args = getopt(args, "", [])
-        if len(args) < 1:
-            print("Usage: dulwich rev-list COMMITID...")
-            sys.exit(1)
-        porcelain.rev_list(".", args)
+        parser = argparse.ArgumentParser()
+        parser.add_argument("commits", nargs="+", help="Commit IDs to list")
+        args = parser.parse_args(args)
+        porcelain.rev_list(".", args.commits)
 
 
 class cmd_tag(Command):
     def run(self, args) -> None:
-        parser = optparse.OptionParser()
-        parser.add_option(
+        parser = argparse.ArgumentParser()
+        parser.add_argument(
             "-a",
             "--annotated",
             help="Create an annotated tag.",
             action="store_true",
         )
-        parser.add_option(
+        parser.add_argument(
             "-s", "--sign", help="Sign the annotated tag.", action="store_true"
         )
-        options, args = parser.parse_args(args)
+        parser.add_argument("tag_name", help="Name of the tag to create")
+        args = parser.parse_args(args)
         porcelain.tag_create(
-            ".", args[0], annotated=options.annotated, sign=options.sign
+            ".", args.tag_name, annotated=args.annotated, sign=args.sign
         )
 
 
 class cmd_repack(Command):
     def run(self, args) -> None:
-        opts, args = getopt(args, "", [])
-        dict(opts)
+        parser = argparse.ArgumentParser()
+        parser.parse_args(args)
         porcelain.repack(".")
 
 
 class cmd_reset(Command):
     def run(self, args) -> None:
-        opts, args = getopt(args, "", ["hard", "soft", "mixed"])
-        kwopts = dict(opts)
-        mode = ""
-        if "--hard" in kwopts:
-            mode = "hard"
-        elif "--soft" in kwopts:
-            mode = "soft"
-        elif "--mixed" in kwopts:
-            mode = "mixed"
-        try:
-            treeish = args.pop(0)
-        except IndexError:
-            treeish = None
-        porcelain.reset(".", mode=mode, treeish=treeish)
+        parser = argparse.ArgumentParser()
+        mode_group = parser.add_mutually_exclusive_group()
+        mode_group.add_argument(
+            "--hard", action="store_true", help="Reset working tree and index"
+        )
+        mode_group.add_argument("--soft", action="store_true", help="Reset only HEAD")
+        mode_group.add_argument(
+            "--mixed", action="store_true", help="Reset HEAD and index"
+        )
+        parser.add_argument("treeish", nargs="?", help="Commit/tree to reset to")
+        args = parser.parse_args(args)
+
+        if args.hard:
+            porcelain.reset(".", mode="hard", treeish=args.treeish)
+        elif args.soft:
+            # Soft reset: only change HEAD
+            if args.treeish:
+                from .repo import Repo
+
+                with Repo(".") as repo:
+                    repo.refs[b"HEAD"] = args.treeish.encode()
+        elif args.mixed:
+            # Mixed reset is not implemented yet
+            raise NotImplementedError("Mixed reset not yet implemented")
+        else:
+            # Default to mixed behavior (not implemented)
+            raise NotImplementedError("Mixed reset not yet implemented")
 
 
 class cmd_daemon(Command):
@@ -442,102 +465,85 @@ class cmd_daemon(Command):
 
         from .protocol import TCP_GIT_PORT
 
-        parser = optparse.OptionParser()
-        parser.add_option(
+        parser = argparse.ArgumentParser()
+        parser.add_argument(
             "-l",
             "--listen_address",
-            dest="listen_address",
             default="localhost",
             help="Binding IP address.",
         )
-        parser.add_option(
+        parser.add_argument(
             "-p",
             "--port",
-            dest="port",
             type=int,
             default=TCP_GIT_PORT,
             help="Binding TCP port.",
         )
-        options, args = parser.parse_args(args)
+        parser.add_argument(
+            "gitdir", nargs="?", default=".", help="Git directory to serve"
+        )
+        args = parser.parse_args(args)
 
         log_utils.default_logging_config()
-        if len(args) >= 1:
-            gitdir = args[0]
-        else:
-            gitdir = "."
-
-        porcelain.daemon(gitdir, address=options.listen_address, port=options.port)
+        porcelain.daemon(args.gitdir, address=args.listen_address, port=args.port)
 
 
 class cmd_web_daemon(Command):
     def run(self, args) -> None:
         from dulwich import log_utils
 
-        parser = optparse.OptionParser()
-        parser.add_option(
+        parser = argparse.ArgumentParser()
+        parser.add_argument(
             "-l",
             "--listen_address",
-            dest="listen_address",
             default="",
             help="Binding IP address.",
         )
-        parser.add_option(
+        parser.add_argument(
             "-p",
             "--port",
-            dest="port",
             type=int,
             default=8000,
             help="Binding TCP port.",
         )
-        options, args = parser.parse_args(args)
+        parser.add_argument(
+            "gitdir", nargs="?", default=".", help="Git directory to serve"
+        )
+        args = parser.parse_args(args)
 
         log_utils.default_logging_config()
-        if len(args) >= 1:
-            gitdir = args[0]
-        else:
-            gitdir = "."
-
-        porcelain.web_daemon(gitdir, address=options.listen_address, port=options.port)
+        porcelain.web_daemon(args.gitdir, address=args.listen_address, port=args.port)
 
 
 class cmd_write_tree(Command):
     def run(self, args) -> None:
-        parser = optparse.OptionParser()
-        options, args = parser.parse_args(args)
-        sys.stdout.write("{}\n".format(porcelain.write_tree(".")))
+        parser = argparse.ArgumentParser()
+        parser.parse_args(args)
+        sys.stdout.write("{}\n".format(porcelain.write_tree(".").decode()))
 
 
 class cmd_receive_pack(Command):
     def run(self, args) -> None:
-        parser = optparse.OptionParser()
-        options, args = parser.parse_args(args)
-        if len(args) >= 1:
-            gitdir = args[0]
-        else:
-            gitdir = "."
-        porcelain.receive_pack(gitdir)
+        parser = argparse.ArgumentParser()
+        parser.add_argument("gitdir", nargs="?", default=".", help="Git directory")
+        args = parser.parse_args(args)
+        porcelain.receive_pack(args.gitdir)
 
 
 class cmd_upload_pack(Command):
     def run(self, args) -> None:
-        parser = optparse.OptionParser()
-        options, args = parser.parse_args(args)
-        if len(args) >= 1:
-            gitdir = args[0]
-        else:
-            gitdir = "."
-        porcelain.upload_pack(gitdir)
+        parser = argparse.ArgumentParser()
+        parser.add_argument("gitdir", nargs="?", default=".", help="Git directory")
+        args = parser.parse_args(args)
+        porcelain.upload_pack(args.gitdir)
 
 
 class cmd_status(Command):
     def run(self, args) -> None:
-        parser = optparse.OptionParser()
-        options, args = parser.parse_args(args)
-        if len(args) >= 1:
-            gitdir = args[0]
-        else:
-            gitdir = "."
-        status = porcelain.status(gitdir)
+        parser = argparse.ArgumentParser()
+        parser.add_argument("gitdir", nargs="?", default=".", help="Git directory")
+        args = parser.parse_args(args)
+        status = porcelain.status(args.gitdir)
         if any(names for (kind, names) in status.staged.items()):
             sys.stdout.write("Changes to be committed:\n\n")
             for kind, names in status.staged.items():
@@ -560,62 +566,66 @@ class cmd_status(Command):
 
 class cmd_ls_remote(Command):
     def run(self, args) -> None:
-        opts, args = getopt(args, "", [])
-        if len(args) < 1:
-            print("Usage: dulwich ls-remote URL")
-            sys.exit(1)
-        refs = porcelain.ls_remote(args[0])
+        parser = argparse.ArgumentParser()
+        parser.add_argument("url", help="Remote URL to list references from")
+        args = parser.parse_args(args)
+        refs = porcelain.ls_remote(args.url)
         for ref in sorted(refs):
             sys.stdout.write(f"{ref}\t{refs[ref]}\n")
 
 
 class cmd_ls_tree(Command):
     def run(self, args) -> None:
-        parser = optparse.OptionParser()
-        parser.add_option(
+        parser = argparse.ArgumentParser()
+        parser.add_argument(
             "-r",
             "--recursive",
             action="store_true",
             help="Recursively list tree contents.",
         )
-        parser.add_option("--name-only", action="store_true", help="Only display name.")
-        options, args = parser.parse_args(args)
-        try:
-            treeish = args.pop(0)
-        except IndexError:
-            treeish = None
+        parser.add_argument(
+            "--name-only", action="store_true", help="Only display name."
+        )
+        parser.add_argument("treeish", nargs="?", help="Tree-ish to list")
+        args = parser.parse_args(args)
         porcelain.ls_tree(
             ".",
-            treeish,
+            args.treeish,
             outstream=sys.stdout,
-            recursive=options.recursive,
-            name_only=options.name_only,
+            recursive=args.recursive,
+            name_only=args.name_only,
         )
 
 
 class cmd_pack_objects(Command):
     def run(self, args) -> None:
-        deltify = False
-        reuse_deltas = True
-        opts, args = getopt(args, "", ["stdout", "deltify", "no-reuse-deltas"])
-        kwopts = dict(opts)
-        if len(args) < 1 and "--stdout" not in kwopts.keys():
-            print("Usage: dulwich pack-objects basename")
-            sys.exit(1)
+        parser = argparse.ArgumentParser()
+        parser.add_argument(
+            "--stdout", action="store_true", help="Write pack to stdout"
+        )
+        parser.add_argument("--deltify", action="store_true", help="Create deltas")
+        parser.add_argument(
+            "--no-reuse-deltas", action="store_true", help="Don't reuse existing deltas"
+        )
+        parser.add_argument("basename", nargs="?", help="Base name for pack files")
+        args = parser.parse_args(args)
+
+        if not args.stdout and not args.basename:
+            parser.error("basename required when not using --stdout")
+
         object_ids = [line.strip() for line in sys.stdin.readlines()]
-        if "--deltify" in kwopts.keys():
-            deltify = True
-        if "--no-reuse-deltas" in kwopts.keys():
-            reuse_deltas = False
-        if "--stdout" in kwopts.keys():
+        deltify = args.deltify
+        reuse_deltas = not args.no_reuse_deltas
+
+        if args.stdout:
             packf = getattr(sys.stdout, "buffer", sys.stdout)
             idxf = None
             close = []
         else:
-            basename = args[0]
-            packf = open(basename + ".pack", "wb")
-            idxf = open(basename + ".idx", "wb")
+            packf = open(args.basename + ".pack", "wb")
+            idxf = open(args.basename + ".idx", "wb")
             close = [packf, idxf]
+
         porcelain.pack_objects(
             ".", object_ids, packf, idxf, deltify=deltify, reuse_deltas=reuse_deltas
         )
@@ -660,9 +670,11 @@ class cmd_push(Command):
 
 class cmd_remote_add(Command):
     def run(self, args) -> None:
-        parser = optparse.OptionParser()
-        options, args = parser.parse_args(args)
-        porcelain.remote_add(".", args[0], args[1])
+        parser = argparse.ArgumentParser()
+        parser.add_argument("name", help="Name of the remote")
+        parser.add_argument("url", help="URL of the remote")
+        args = parser.parse_args(args)
+        porcelain.remote_add(".", args.name, args.url)
 
 
 class SuperCommand(Command):
@@ -670,11 +682,16 @@ class SuperCommand(Command):
     default_command: ClassVar[Optional[type[Command]]] = None
 
     def run(self, args):
-        if not args and not self.default_command:
-            print(
-                "Supported subcommands: {}".format(", ".join(self.subcommands.keys()))
-            )
-            return False
+        if not args:
+            if self.default_command:
+                return self.default_command().run(args)
+            else:
+                print(
+                    "Supported subcommands: {}".format(
+                        ", ".join(self.subcommands.keys())
+                    )
+                )
+                return False
         cmd = args[0]
         try:
             cmd_kls = self.subcommands[cmd]
@@ -708,17 +725,19 @@ class cmd_submodule_init(Command):
 class cmd_submodule(SuperCommand):
     subcommands: ClassVar[dict[str, type[Command]]] = {
         "init": cmd_submodule_init,
+        "list": cmd_submodule_list,
     }
 
-    default_command = cmd_submodule_init
+    default_command = cmd_submodule_list
 
 
 class cmd_check_ignore(Command):
     def run(self, args):
-        parser = optparse.OptionParser()
-        options, args = parser.parse_args(args)
+        parser = argparse.ArgumentParser()
+        parser.add_argument("paths", nargs="+", help="Paths to check")
+        args = parser.parse_args(args)
         ret = 1
-        for path in porcelain.check_ignore(".", args):
+        for path in porcelain.check_ignore(".", args.paths):
             print(path)
             ret = 0
         return ret
@@ -726,10 +745,11 @@ class cmd_check_ignore(Command):
 
 class cmd_check_mailmap(Command):
     def run(self, args) -> None:
-        parser = optparse.OptionParser()
-        options, args = parser.parse_args(args)
-        for arg in args:
-            canonical_identity = porcelain.check_mailmap(".", arg)
+        parser = argparse.ArgumentParser()
+        parser.add_argument("identities", nargs="+", help="Identities to check")
+        args = parser.parse_args(args)
+        for identity in args.identities:
+            canonical_identity = porcelain.check_mailmap(".", identity)
             print(canonical_identity)
 
 
@@ -798,24 +818,24 @@ class cmd_checkout(Command):
 
 class cmd_stash_list(Command):
     def run(self, args) -> None:
-        parser = optparse.OptionParser()
-        options, args = parser.parse_args(args)
+        parser = argparse.ArgumentParser()
+        parser.parse_args(args)
         for i, entry in porcelain.stash_list("."):
             print("stash@{{{}}}: {}".format(i, entry.message.rstrip("\n")))
 
 
 class cmd_stash_push(Command):
     def run(self, args) -> None:
-        parser = optparse.OptionParser()
-        options, args = parser.parse_args(args)
+        parser = argparse.ArgumentParser()
+        parser.parse_args(args)
         porcelain.stash_push(".")
         print("Saved working directory and index state")
 
 
 class cmd_stash_pop(Command):
     def run(self, args) -> None:
-        parser = optparse.OptionParser()
-        options, args = parser.parse_args(args)
+        parser = argparse.ArgumentParser()
+        parser.parse_args(args)
         porcelain.stash_pop(".")
         print("Restored working directory and index state")
 
@@ -830,16 +850,16 @@ class cmd_stash(SuperCommand):
 
 class cmd_ls_files(Command):
     def run(self, args) -> None:
-        parser = optparse.OptionParser()
-        options, args = parser.parse_args(args)
+        parser = argparse.ArgumentParser()
+        parser.parse_args(args)
         for name in porcelain.ls_files("."):
             print(name)
 
 
 class cmd_describe(Command):
     def run(self, args) -> None:
-        parser = optparse.OptionParser()
-        options, args = parser.parse_args(args)
+        parser = argparse.ArgumentParser()
+        parser.parse_args(args)
         print(porcelain.describe("."))
 
 
@@ -888,17 +908,16 @@ class cmd_merge(Command):
 
 class cmd_help(Command):
     def run(self, args) -> None:
-        parser = optparse.OptionParser()
-        parser.add_option(
+        parser = argparse.ArgumentParser()
+        parser.add_argument(
             "-a",
             "--all",
-            dest="all",
             action="store_true",
             help="List all commands.",
         )
-        options, args = parser.parse_args(args)
+        args = parser.parse_args(args)
 
-        if options.all:
+        if args.all:
             print("Available commands:")
             for cmd in sorted(commands):
                 print(f"  {cmd}")

+ 5 - 4
dulwich/porcelain.py

@@ -2425,7 +2425,7 @@ def stash_pop(repo) -> None:
         from .stash import Stash
 
         stash = Stash.from_repo(r)
-        stash.pop()
+        stash.pop(0)
 
 
 def stash_drop(repo, index) -> None:
@@ -2475,11 +2475,12 @@ def describe(repo, abbrev=None):
             _, tag = key.rsplit("/", 1)
 
             try:
+                # Annotated tag case
                 commit = obj.object
-            except AttributeError:
-                continue
-            else:
                 commit = r.get_object(commit[1])
+            except AttributeError:
+                # Lightweight tag case - obj is already the commit
+                commit = obj
             tags[tag] = [
                 datetime.datetime(*time.gmtime(commit.commit_time)[:6]),
                 commit.id.decode("ascii"),

+ 1 - 0
tests/__init__.py

@@ -118,6 +118,7 @@ def self_test_suite():
         "archive",
         "blackbox",
         "bundle",
+        "cli",
         "cli_merge",
         "client",
         "cloud_gcs",

+ 671 - 0
tests/test_cli.py

@@ -0,0 +1,671 @@
+#!/usr/bin/env python
+# test_cli.py -- tests for dulwich.cli
+# vim: expandtab
+#
+# Copyright (C) 2024 Jelmer Vernooij <jelmer@jelmer.uk>
+#
+# SPDX-License-Identifier: Apache-2.0 OR GPL-2.0-or-later
+# Dulwich is dual-licensed under the Apache License, Version 2.0 and the GNU
+# General Public License as public by the Free Software Foundation; version 2.0
+# or (at your option) any later version. You can redistribute it and/or
+# modify it under the terms of either of these two licenses.
+#
+# Unless required by applicable law or agreed to in writing, software
+# distributed under the License is distributed on an "AS IS" BASIS,
+# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+# See the License for the specific language governing permissions and
+# limitations under the License.
+#
+# You should have received a copy of the licenses; if not, see
+# <http://www.gnu.org/licenses/> for a copy of the GNU General Public License
+# and <http://www.apache.org/licenses/LICENSE-2.0> for a copy of the Apache
+# License, Version 2.0.
+
+"""Tests for dulwich.cli."""
+
+import io
+import os
+import shutil
+import sys
+import tempfile
+import unittest
+from unittest.mock import MagicMock, patch
+
+from dulwich import cli
+from dulwich.repo import Repo
+from dulwich.tests.utils import (
+    build_commit_graph,
+)
+
+from . import TestCase
+
+
+class DulwichCliTestCase(TestCase):
+    """Base class for CLI tests."""
+
+    def setUp(self) -> None:
+        super().setUp()
+        self.test_dir = tempfile.mkdtemp()
+        self.addCleanup(shutil.rmtree, self.test_dir)
+        self.repo_path = os.path.join(self.test_dir, "repo")
+        os.mkdir(self.repo_path)
+        self.repo = Repo.init(self.repo_path)
+        self.addCleanup(self.repo.close)
+
+    def _run_cli(self, *args, stdout_stream=None):
+        """Run CLI command and capture output."""
+        old_stdout = sys.stdout
+        old_stderr = sys.stderr
+        old_cwd = os.getcwd()
+        try:
+            sys.stdout = stdout_stream or io.StringIO()
+            sys.stderr = io.StringIO()
+            os.chdir(self.repo_path)
+            result = cli.main(list(args))
+            return result, sys.stdout.getvalue(), sys.stderr.getvalue()
+        finally:
+            sys.stdout = old_stdout
+            sys.stderr = old_stderr
+            os.chdir(old_cwd)
+
+
+class InitCommandTest(DulwichCliTestCase):
+    """Tests for init command."""
+
+    def test_init_basic(self):
+        # Create a new directory for init
+        new_repo_path = os.path.join(self.test_dir, "new_repo")
+        result, stdout, stderr = self._run_cli("init", new_repo_path)
+        self.assertTrue(os.path.exists(os.path.join(new_repo_path, ".git")))
+
+    def test_init_bare(self):
+        # Create a new directory for bare repo
+        bare_repo_path = os.path.join(self.test_dir, "bare_repo")
+        result, stdout, stderr = self._run_cli("init", "--bare", bare_repo_path)
+        self.assertTrue(os.path.exists(os.path.join(bare_repo_path, "HEAD")))
+        self.assertFalse(os.path.exists(os.path.join(bare_repo_path, ".git")))
+
+
+class AddCommandTest(DulwichCliTestCase):
+    """Tests for add command."""
+
+    def test_add_single_file(self):
+        # Create a file to add
+        test_file = os.path.join(self.repo_path, "test.txt")
+        with open(test_file, "w") as f:
+            f.write("test content")
+
+        result, stdout, stderr = self._run_cli("add", "test.txt")
+        # Check that file is in index
+        self.assertIn(b"test.txt", self.repo.open_index())
+
+    def test_add_multiple_files(self):
+        # Create multiple files
+        for i in range(3):
+            test_file = os.path.join(self.repo_path, f"test{i}.txt")
+            with open(test_file, "w") as f:
+                f.write(f"content {i}")
+
+        result, stdout, stderr = self._run_cli(
+            "add", "test0.txt", "test1.txt", "test2.txt"
+        )
+        index = self.repo.open_index()
+        self.assertIn(b"test0.txt", index)
+        self.assertIn(b"test1.txt", index)
+        self.assertIn(b"test2.txt", index)
+
+
+class RmCommandTest(DulwichCliTestCase):
+    """Tests for rm command."""
+
+    def test_rm_file(self):
+        # Create, add and commit a file first
+        test_file = os.path.join(self.repo_path, "test.txt")
+        with open(test_file, "w") as f:
+            f.write("test content")
+        self._run_cli("add", "test.txt")
+        self._run_cli("commit", "--message=Add test file")
+
+        # Now remove it from index and working directory
+        result, stdout, stderr = self._run_cli("rm", "test.txt")
+        # Check that file is not in index
+        self.assertNotIn(b"test.txt", self.repo.open_index())
+
+
+class CommitCommandTest(DulwichCliTestCase):
+    """Tests for commit command."""
+
+    def test_commit_basic(self):
+        # Create and add a file
+        test_file = os.path.join(self.repo_path, "test.txt")
+        with open(test_file, "w") as f:
+            f.write("test content")
+        self._run_cli("add", "test.txt")
+
+        # Commit
+        result, stdout, stderr = self._run_cli("commit", "--message=Initial commit")
+        # Check that HEAD points to a commit
+        self.assertIsNotNone(self.repo.head())
+
+
+class LogCommandTest(DulwichCliTestCase):
+    """Tests for log command."""
+
+    def test_log_empty_repo(self):
+        result, stdout, stderr = self._run_cli("log")
+        # Empty repo should not crash
+
+    def test_log_with_commits(self):
+        # Create some commits
+        c1, c2, c3 = build_commit_graph(
+            self.repo.object_store, [[1], [2, 1], [3, 1, 2]]
+        )
+        self.repo.refs[b"HEAD"] = c3.id
+
+        result, stdout, stderr = self._run_cli("log")
+        self.assertIn("Commit 3", stdout)
+        self.assertIn("Commit 2", stdout)
+        self.assertIn("Commit 1", stdout)
+
+    def test_log_reverse(self):
+        # Create some commits
+        c1, c2, c3 = build_commit_graph(
+            self.repo.object_store, [[1], [2, 1], [3, 1, 2]]
+        )
+        self.repo.refs[b"HEAD"] = c3.id
+
+        result, stdout, stderr = self._run_cli("log", "--reverse")
+        # Check order - commit 1 should appear before commit 3
+        pos1 = stdout.index("Commit 1")
+        pos3 = stdout.index("Commit 3")
+        self.assertLess(pos1, pos3)
+
+
+class StatusCommandTest(DulwichCliTestCase):
+    """Tests for status command."""
+
+    def test_status_empty(self):
+        result, stdout, stderr = self._run_cli("status")
+        # Should not crash on empty repo
+
+    def test_status_with_untracked(self):
+        # Create an untracked file
+        test_file = os.path.join(self.repo_path, "untracked.txt")
+        with open(test_file, "w") as f:
+            f.write("untracked content")
+
+        result, stdout, stderr = self._run_cli("status")
+        self.assertIn("Untracked files:", stdout)
+        self.assertIn("untracked.txt", stdout)
+
+
+class BranchCommandTest(DulwichCliTestCase):
+    """Tests for branch command."""
+
+    def test_branch_create(self):
+        # Create initial commit
+        test_file = os.path.join(self.repo_path, "test.txt")
+        with open(test_file, "w") as f:
+            f.write("test")
+        self._run_cli("add", "test.txt")
+        self._run_cli("commit", "--message=Initial")
+
+        # Create branch
+        result, stdout, stderr = self._run_cli("branch", "test-branch")
+        self.assertIn(b"refs/heads/test-branch", self.repo.refs.keys())
+
+    def test_branch_delete(self):
+        # Create initial commit and branch
+        test_file = os.path.join(self.repo_path, "test.txt")
+        with open(test_file, "w") as f:
+            f.write("test")
+        self._run_cli("add", "test.txt")
+        self._run_cli("commit", "--message=Initial")
+        self._run_cli("branch", "test-branch")
+
+        # Delete branch
+        result, stdout, stderr = self._run_cli("branch", "-d", "test-branch")
+        self.assertNotIn(b"refs/heads/test-branch", self.repo.refs.keys())
+
+
+class CheckoutCommandTest(DulwichCliTestCase):
+    """Tests for checkout command."""
+
+    def test_checkout_branch(self):
+        # Create initial commit and branch
+        test_file = os.path.join(self.repo_path, "test.txt")
+        with open(test_file, "w") as f:
+            f.write("test")
+        self._run_cli("add", "test.txt")
+        self._run_cli("commit", "--message=Initial")
+        self._run_cli("branch", "test-branch")
+
+        # Checkout branch
+        result, stdout, stderr = self._run_cli("checkout", "test-branch")
+        self.assertEqual(
+            self.repo.refs.read_ref(b"HEAD"), b"ref: refs/heads/test-branch"
+        )
+
+
+class TagCommandTest(DulwichCliTestCase):
+    """Tests for tag command."""
+
+    def test_tag_create(self):
+        # Create initial commit
+        test_file = os.path.join(self.repo_path, "test.txt")
+        with open(test_file, "w") as f:
+            f.write("test")
+        self._run_cli("add", "test.txt")
+        self._run_cli("commit", "--message=Initial")
+
+        # Create tag
+        result, stdout, stderr = self._run_cli("tag", "v1.0")
+        self.assertIn(b"refs/tags/v1.0", self.repo.refs.keys())
+
+
+class ShowCommandTest(DulwichCliTestCase):
+    """Tests for show command."""
+
+    def test_show_commit(self):
+        # Create a commit
+        test_file = os.path.join(self.repo_path, "test.txt")
+        with open(test_file, "w") as f:
+            f.write("test content")
+        self._run_cli("add", "test.txt")
+        self._run_cli("commit", "--message=Test commit")
+
+        result, stdout, stderr = self._run_cli("show", "HEAD")
+        self.assertIn("Test commit", stdout)
+
+
+class FetchPackCommandTest(DulwichCliTestCase):
+    """Tests for fetch-pack command."""
+
+    @patch("dulwich.cli.get_transport_and_path")
+    def test_fetch_pack_basic(self, mock_transport):
+        # Mock the transport
+        mock_client = MagicMock()
+        mock_transport.return_value = (mock_client, "/path/to/repo")
+        mock_client.fetch.return_value = None
+
+        result, stdout, stderr = self._run_cli(
+            "fetch-pack", "git://example.com/repo.git"
+        )
+        mock_client.fetch.assert_called_once()
+
+
+class PullCommandTest(DulwichCliTestCase):
+    """Tests for pull command."""
+
+    @patch("dulwich.porcelain.pull")
+    def test_pull_basic(self, mock_pull):
+        result, stdout, stderr = self._run_cli("pull", "origin")
+        mock_pull.assert_called_once()
+
+    @patch("dulwich.porcelain.pull")
+    def test_pull_with_refspec(self, mock_pull):
+        result, stdout, stderr = self._run_cli("pull", "origin", "master")
+        mock_pull.assert_called_once()
+
+
+class PushCommandTest(DulwichCliTestCase):
+    """Tests for push command."""
+
+    @patch("dulwich.porcelain.push")
+    def test_push_basic(self, mock_push):
+        result, stdout, stderr = self._run_cli("push", "origin")
+        mock_push.assert_called_once()
+
+    @patch("dulwich.porcelain.push")
+    def test_push_force(self, mock_push):
+        result, stdout, stderr = self._run_cli("push", "-f", "origin")
+        mock_push.assert_called_with(".", "origin", None, force=True)
+
+
+class ArchiveCommandTest(DulwichCliTestCase):
+    """Tests for archive command."""
+
+    def test_archive_basic(self):
+        # Create a commit
+        test_file = os.path.join(self.repo_path, "test.txt")
+        with open(test_file, "w") as f:
+            f.write("test content")
+        self._run_cli("add", "test.txt")
+        self._run_cli("commit", "--message=Initial")
+
+        # Archive produces binary output, so use BytesIO
+        result, stdout, stderr = self._run_cli(
+            "archive", "HEAD", stdout_stream=io.BytesIO()
+        )
+        # Should complete without error and produce some binary output
+        self.assertIsInstance(stdout, bytes)
+        self.assertGreater(len(stdout), 0)
+
+
+class ForEachRefCommandTest(DulwichCliTestCase):
+    """Tests for for-each-ref command."""
+
+    def test_for_each_ref(self):
+        # Create a commit
+        test_file = os.path.join(self.repo_path, "test.txt")
+        with open(test_file, "w") as f:
+            f.write("test")
+        self._run_cli("add", "test.txt")
+        self._run_cli("commit", "--message=Initial")
+
+        result, stdout, stderr = self._run_cli("for-each-ref")
+        self.assertIn("refs/heads/master", stdout)
+
+
+class PackRefsCommandTest(DulwichCliTestCase):
+    """Tests for pack-refs command."""
+
+    def test_pack_refs(self):
+        # Create some refs
+        test_file = os.path.join(self.repo_path, "test.txt")
+        with open(test_file, "w") as f:
+            f.write("test")
+        self._run_cli("add", "test.txt")
+        self._run_cli("commit", "--message=Initial")
+        self._run_cli("branch", "test-branch")
+
+        result, stdout, stderr = self._run_cli("pack-refs", "--all")
+        # Check that packed-refs file exists
+        self.assertTrue(
+            os.path.exists(os.path.join(self.repo_path, ".git", "packed-refs"))
+        )
+
+
+class SubmoduleCommandTest(DulwichCliTestCase):
+    """Tests for submodule commands."""
+
+    def test_submodule_list(self):
+        # Create an initial commit so repo has a HEAD
+        test_file = os.path.join(self.repo_path, "test.txt")
+        with open(test_file, "w") as f:
+            f.write("test")
+        self._run_cli("add", "test.txt")
+        self._run_cli("commit", "--message=Initial")
+
+        result, stdout, stderr = self._run_cli("submodule")
+        # Should not crash on repo without submodules
+
+    def test_submodule_init(self):
+        # Create .gitmodules file for init to work
+        gitmodules = os.path.join(self.repo_path, ".gitmodules")
+        with open(gitmodules, "w") as f:
+            f.write("")  # Empty .gitmodules file
+
+        result, stdout, stderr = self._run_cli("submodule", "init")
+        # Should not crash
+
+
+class StashCommandTest(DulwichCliTestCase):
+    """Tests for stash commands."""
+
+    def test_stash_list_empty(self):
+        result, stdout, stderr = self._run_cli("stash", "list")
+        # Should not crash on empty stash
+
+    def test_stash_push_pop(self):
+        # Create a file and modify it
+        test_file = os.path.join(self.repo_path, "test.txt")
+        with open(test_file, "w") as f:
+            f.write("initial")
+        self._run_cli("add", "test.txt")
+        self._run_cli("commit", "--message=Initial")
+
+        # Modify file
+        with open(test_file, "w") as f:
+            f.write("modified")
+
+        # Stash changes
+        result, stdout, stderr = self._run_cli("stash", "push")
+        self.assertIn("Saved working directory", stdout)
+
+        # Note: Dulwich stash doesn't currently update the working tree
+        # so the file remains modified after stash push
+
+        # Note: stash pop is not fully implemented in Dulwich yet
+        # so we only test stash push here
+
+
+class MergeCommandTest(DulwichCliTestCase):
+    """Tests for merge command."""
+
+    def test_merge_basic(self):
+        # Create initial commit
+        test_file = os.path.join(self.repo_path, "test.txt")
+        with open(test_file, "w") as f:
+            f.write("initial")
+        self._run_cli("add", "test.txt")
+        self._run_cli("commit", "--message=Initial")
+
+        # Create and checkout new branch
+        self._run_cli("branch", "feature")
+        self._run_cli("checkout", "feature")
+
+        # Make changes in feature branch
+        with open(test_file, "w") as f:
+            f.write("feature changes")
+        self._run_cli("add", "test.txt")
+        self._run_cli("commit", "--message=Feature commit")
+
+        # Go back to main
+        self._run_cli("checkout", "master")
+
+        # Merge feature branch
+        result, stdout, stderr = self._run_cli("merge", "feature")
+
+
+class HelpCommandTest(DulwichCliTestCase):
+    """Tests for help command."""
+
+    def test_help_basic(self):
+        result, stdout, stderr = self._run_cli("help")
+        self.assertIn("dulwich command line tool", stdout)
+
+    def test_help_all(self):
+        result, stdout, stderr = self._run_cli("help", "-a")
+        self.assertIn("Available commands:", stdout)
+        self.assertIn("add", stdout)
+        self.assertIn("commit", stdout)
+
+
+class RemoteCommandTest(DulwichCliTestCase):
+    """Tests for remote commands."""
+
+    def test_remote_add(self):
+        result, stdout, stderr = self._run_cli(
+            "remote", "add", "origin", "https://github.com/example/repo.git"
+        )
+        # Check remote was added to config
+        config = self.repo.get_config()
+        self.assertEqual(
+            config.get((b"remote", b"origin"), b"url"),
+            b"https://github.com/example/repo.git",
+        )
+
+
+class CheckIgnoreCommandTest(DulwichCliTestCase):
+    """Tests for check-ignore command."""
+
+    def test_check_ignore(self):
+        # Create .gitignore
+        gitignore = os.path.join(self.repo_path, ".gitignore")
+        with open(gitignore, "w") as f:
+            f.write("*.log\n")
+
+        result, stdout, stderr = self._run_cli("check-ignore", "test.log", "test.txt")
+        self.assertIn("test.log", stdout)
+        self.assertNotIn("test.txt", stdout)
+
+
+class LsFilesCommandTest(DulwichCliTestCase):
+    """Tests for ls-files command."""
+
+    def test_ls_files(self):
+        # Add some files
+        for name in ["a.txt", "b.txt", "c.txt"]:
+            path = os.path.join(self.repo_path, name)
+            with open(path, "w") as f:
+                f.write(f"content of {name}")
+        self._run_cli("add", "a.txt", "b.txt", "c.txt")
+
+        result, stdout, stderr = self._run_cli("ls-files")
+        self.assertIn("a.txt", stdout)
+        self.assertIn("b.txt", stdout)
+        self.assertIn("c.txt", stdout)
+
+
+class LsTreeCommandTest(DulwichCliTestCase):
+    """Tests for ls-tree command."""
+
+    def test_ls_tree(self):
+        # Create a directory structure
+        os.mkdir(os.path.join(self.repo_path, "subdir"))
+        with open(os.path.join(self.repo_path, "file.txt"), "w") as f:
+            f.write("file content")
+        with open(os.path.join(self.repo_path, "subdir", "nested.txt"), "w") as f:
+            f.write("nested content")
+
+        self._run_cli("add", ".")
+        self._run_cli("commit", "--message=Initial")
+
+        result, stdout, stderr = self._run_cli("ls-tree", "HEAD")
+        self.assertIn("file.txt", stdout)
+        self.assertIn("subdir", stdout)
+
+    def test_ls_tree_recursive(self):
+        # Create nested structure
+        os.mkdir(os.path.join(self.repo_path, "subdir"))
+        with open(os.path.join(self.repo_path, "subdir", "nested.txt"), "w") as f:
+            f.write("nested")
+
+        self._run_cli("add", ".")
+        self._run_cli("commit", "--message=Initial")
+
+        result, stdout, stderr = self._run_cli("ls-tree", "-r", "HEAD")
+        self.assertIn("subdir/nested.txt", stdout)
+
+
+class DescribeCommandTest(DulwichCliTestCase):
+    """Tests for describe command."""
+
+    def test_describe(self):
+        # Create tagged commit
+        test_file = os.path.join(self.repo_path, "test.txt")
+        with open(test_file, "w") as f:
+            f.write("test")
+        self._run_cli("add", "test.txt")
+        self._run_cli("commit", "--message=Initial")
+        self._run_cli("tag", "v1.0")
+
+        result, stdout, stderr = self._run_cli("describe")
+        self.assertIn("v1.0", stdout)
+
+
+class FsckCommandTest(DulwichCliTestCase):
+    """Tests for fsck command."""
+
+    def test_fsck(self):
+        # Create a commit
+        test_file = os.path.join(self.repo_path, "test.txt")
+        with open(test_file, "w") as f:
+            f.write("test")
+        self._run_cli("add", "test.txt")
+        self._run_cli("commit", "--message=Initial")
+
+        result, stdout, stderr = self._run_cli("fsck")
+        # Should complete without errors
+
+
+class RepackCommandTest(DulwichCliTestCase):
+    """Tests for repack command."""
+
+    def test_repack(self):
+        # Create some objects
+        for i in range(5):
+            test_file = os.path.join(self.repo_path, f"test{i}.txt")
+            with open(test_file, "w") as f:
+                f.write(f"content {i}")
+            self._run_cli("add", f"test{i}.txt")
+            self._run_cli("commit", f"--message=Commit {i}")
+
+        result, stdout, stderr = self._run_cli("repack")
+        # Should create pack files
+        pack_dir = os.path.join(self.repo_path, ".git", "objects", "pack")
+        self.assertTrue(any(f.endswith(".pack") for f in os.listdir(pack_dir)))
+
+
+class ResetCommandTest(DulwichCliTestCase):
+    """Tests for reset command."""
+
+    def test_reset_soft(self):
+        # Create commits
+        test_file = os.path.join(self.repo_path, "test.txt")
+        with open(test_file, "w") as f:
+            f.write("first")
+        self._run_cli("add", "test.txt")
+        self._run_cli("commit", "--message=First")
+        first_commit = self.repo.head()
+
+        with open(test_file, "w") as f:
+            f.write("second")
+        self._run_cli("add", "test.txt")
+        self._run_cli("commit", "--message=Second")
+
+        # Reset soft
+        result, stdout, stderr = self._run_cli("reset", "--soft", first_commit.decode())
+        # HEAD should be at first commit
+        self.assertEqual(self.repo.head(), first_commit)
+
+
+class WriteTreeCommandTest(DulwichCliTestCase):
+    """Tests for write-tree command."""
+
+    def test_write_tree(self):
+        # Create and add files
+        test_file = os.path.join(self.repo_path, "test.txt")
+        with open(test_file, "w") as f:
+            f.write("test")
+        self._run_cli("add", "test.txt")
+
+        result, stdout, stderr = self._run_cli("write-tree")
+        # Should output tree SHA
+        self.assertEqual(len(stdout.strip()), 40)
+
+
+class UpdateServerInfoCommandTest(DulwichCliTestCase):
+    """Tests for update-server-info command."""
+
+    def test_update_server_info(self):
+        result, stdout, stderr = self._run_cli("update-server-info")
+        # Should create info/refs file
+        info_refs = os.path.join(self.repo_path, ".git", "info", "refs")
+        self.assertTrue(os.path.exists(info_refs))
+
+
+class SymbolicRefCommandTest(DulwichCliTestCase):
+    """Tests for symbolic-ref command."""
+
+    def test_symbolic_ref(self):
+        # Create a branch
+        test_file = os.path.join(self.repo_path, "test.txt")
+        with open(test_file, "w") as f:
+            f.write("test")
+        self._run_cli("add", "test.txt")
+        self._run_cli("commit", "--message=Initial")
+        self._run_cli("branch", "test-branch")
+
+        result, stdout, stderr = self._run_cli(
+            "symbolic-ref", "HEAD", "refs/heads/test-branch"
+        )
+        # HEAD should now point to test-branch
+        self.assertEqual(
+            self.repo.refs.read_ref(b"HEAD"), b"ref: refs/heads/test-branch"
+        )
+
+
+if __name__ == "__main__":
+    unittest.main()